TheNetworg / oauth2-azure

Azure AD provider for the OAuth 2.0 Client.
https://packagist.org/packages/thenetworg/oauth2-azure
MIT License
230 stars 108 forks source link

Caching JWT verification keys & OpenID configurtion #111

Open sadika9 opened 4 years ago

sadika9 commented 4 years ago

validateAccessToken() method calls getJwtVerificationKeys() to fetch verification keys in each token verification. This led to throttle our request by Azure/Microsoft in the past. To work around that we previously extended the TheNetworg\OAuth2\Client\Provider\Azure provider to modify getJwtVerificationKeys() behaviour (used Laravel cache).

By looking at again I also noticed that getOpenIdConfiguration() also request for config each time (e.g. in two separate Laravel API requests).

In Validating the signature it says that we can cache keys for about 24 hours.

Is there a clean method/suggestion to implement caching?

Glideh commented 1 year ago

This seems important to me because having an API which systematically retrieves the keys from Azure can have a significant impact on the average response delay. Measured on my dev env from an average 50ms response time it becomes 150ms with the keys retrieval. Even if it's not fully relevant on a dev env it gives an idea.

We should inject a cache adapter in the Azure provider so it can be used around here: https://github.com/TheNetworg/oauth2-azure/blob/06fb2d620fb6e6c934f632c7ec7c5ea2e978a844/src/Provider/Azure.php#L337-L345

Additionally this Microsoft official article about keys rollover can be useful for the implementation. As @sadika9 said, a 24h rollover is needed but also a maximum 5min throttled refresh in case of signature check fail.

Checking every 24 hours for updates is a best practice, with throttled (once every five minutes at most) immediate refreshes of the key document if a token is encountered that doesn't validate with the keys in your application's cache.

Glideh commented 1 year ago

Here is how I implemented the Microsoft recommendations for keys rollover:

class MyAzureAuthenticator extends AbstractGuardAuthenticator
{
// ...
    /** @var Azure $provider */
    private $provider;
    /** @var AbstractAdapter $cache */
    private $cache;
    private $refreshDelayStandard = 3600 * 24; // One day
    private $refreshDelayShort = 60 * 5; // 5 minutes

    public function __construct()
    {
        $this->provider = new Azure();
        $this->cache = new ApcuAdapter('unique-string-for-app');
    }

    // One day cached keys
    private function getKeys($force = false)
    {
        return $this->cache->get('microsoft-keys', function (ItemInterface $item) {
            $item->expiresAfter($this->refreshDelayStandard);
            return $this->provider->getJwtVerificationKeys();
        }, $force ? INF : 1.0);
    }

    // 5 minutes cached keys
    private function getKeysShort()
    {
        return $this->cache->get('microsoft-keys-short', function (ItemInterface $item) {
            $item->expiresAfter($this->refreshDelayShort);
            // Forces the one day cache refresh
            return $this->getKeys(true);
        });
    }

    public function getCredentials(Request $request)
    {
        $accessToken = $this->extractAccessToken($request);

        // 2 attempts
        foreach ([0, 1] as $try) {
            $firstTry = $try === 0;
            try {
                // First tries to use the "one day" cached keys, then falls back on the "5 minutes" cache
                // (if signature check failed) which forces the "one day" cache refresh
                $keys = $firstTry ? $this->getKeys() : $this->getKeysShort();
                return (array)JWT::decode($accessToken, $keys);
            } catch (SignatureInvalidException $e) {
                if ($firstTry) continue;
                throw new HttpException(403, $e->getMessage(), $e);
            }
        }
    }

// ...
}

This is a part of my authenticator for Symfony, the whole thing can be found here

sadika9 commented 1 year ago

Here's my cache implementation for Laravel:

Azure AD:

<?php

namespace App\Support\OAuth2\Client\Provider;

use Illuminate\Support\Facades\Cache;
use TheNetworg\OAuth2\Client\Provider\Azure;

class AzureCached extends Azure
{
    protected function getOpenIdConfiguration($tenant, $version)
    {
        $cacheKey = "azure_ad.{$tenant}.{$version}.openid_config";

        return $this->cache()->remember($cacheKey, 3600, function () use ($tenant, $version) {
            return parent::getOpenIdConfiguration($tenant, $version);
        });
    }

    public function getJwtVerificationKeys()
    {
        $cacheKey = "azure_ad.{$this->tenant}.{$this->defaultEndPointVersion}.jwt_verification_keys";

        return $this->cache()->remember($cacheKey, 86400, function () {
            return parent::getJwtVerificationKeys();
        });
    }

    private function cache()
    {
        return Cache::store('file');
    }
}

Azure AD B2C (with few other fixes):

<?php

namespace App\Support\OAuth2\Client\Provider;

use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Math\BigInteger;
use RuntimeException;
use TheNetworg\OAuth2\Client\Provider\Azure;

class AzureB2CCached extends Azure
{
    /**
     * @var string
     */
    protected $tenantName;

    /**
     * @var string Policy name
     */
    protected $policyName;

    protected function getOpenIdConfiguration($tenant, $version)
    {
        $cacheKey = "azure_ad.b2c.{$tenant}.{$version}.{$this->policyName}.openid_config";

        return $this->cache()->remember($cacheKey, 3600, function () use ($tenant, $version) {
            return $this->__getOpenIdConfiguration($tenant, $version);
        });
    }

    /**
     * Modified $openIdConfigurationUri
     *
     * @param string $tenant
     * @param string $version
     * @return mixed
     * @throws IdentityProviderException
     */
    private function __getOpenIdConfiguration($tenant, $version)
    {
        if (!is_array($this->openIdConfiguration)) {
            $this->openIdConfiguration = [];
        }
        if (!array_key_exists($tenant, $this->openIdConfiguration)) {
            $this->openIdConfiguration[$tenant] = [];
        }
        if (!array_key_exists($version, $this->openIdConfiguration[$tenant])) {
            $versionInfix = $this->getVersionUriInfix($version);
            $openIdConfigurationUri = 'https://' . $this->tenantName . '.b2clogin.com/' . $this->tenantName . '.onmicrosoft.com/' . $this->policyName . $versionInfix . '/.well-known/openid-configuration';
            $factory = $this->getRequestFactory();
            $request = $factory->getRequestWithOptions(
                'get',
                $openIdConfigurationUri,
                []
            );
            $response = $this->getParsedResponse($request);
            $this->openIdConfiguration[$tenant][$version] = $response;
        }

        return $this->openIdConfiguration[$tenant][$version];
    }

    public function getJwtVerificationKeys()
    {
        $cacheKey = "azure_ad.b2c.{$this->tenant}.{$this->defaultEndPointVersion}.{$this->policyName}.jwt_verification_keys";

        return $this->cache()->remember($cacheKey, 86400, function () {
            return $this->__getJwtVerificationKeys();
        });
    }

    /**
     * Modified to build public key from e, n values
     *
     * @throws IdentityProviderException
     */
    private function __getJwtVerificationKeys(): array
    {
        $openIdConfiguration = $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion);
        $keysUri = $openIdConfiguration['jwks_uri'];

        $factory = $this->getRequestFactory();
        $request = $factory->getRequestWithOptions('get', $keysUri, []);

        $response = $this->getParsedResponse($request);

        $keys = [];
        foreach ($response['keys'] as $keyinfo) {
            if (isset($keyinfo['x5c']) && is_array($keyinfo['x5c'])) {
                foreach ($keyinfo['x5c'] as $encodedkey) {
                    $cert =
                        '-----BEGIN CERTIFICATE-----' . PHP_EOL
                        . chunk_split($encodedkey, 64,  PHP_EOL)
                        . '-----END CERTIFICATE-----' . PHP_EOL;

                    $cert_object = openssl_x509_read($cert);

                    if ($cert_object === false) {
                        throw new RuntimeException('An attempt to read ' . $encodedkey . ' as a certificate failed.');
                    }

                    $pkey_object = openssl_pkey_get_public($cert_object);

                    if ($pkey_object === false) {
                        throw new RuntimeException('An attempt to read a public key from a ' . $encodedkey . ' certificate failed.');
                    }

                    $pkey_array = openssl_pkey_get_details($pkey_object);

                    if ($pkey_array === false) {
                        throw new RuntimeException('An attempt to get a public key as an array from a ' . $encodedkey . ' certificate failed.');
                    }

                    $publicKey = $pkey_array ['key'];

                    $keys[$keyinfo['kid']] = $publicKey;
                }
            } else if ($keyinfo['e'] && $keyinfo['n']) {
                $exponent = JWT::urlsafeB64Decode($keyinfo['e']);
                $modulus = JWT::urlsafeB64Decode($keyinfo['n']);

                $exponent = new BigInteger($exponent, 256);
                $modulus = new BigInteger($modulus, 256);

                $key = PublicKeyLoader::load([
                    'e' => $exponent,
                    'n' => $modulus
                ]);

                $keys[$keyinfo['kid']] = $key->toString('PKCS8');
            }
        }

        return $keys;
    }

    private function cache()
    {
        return Cache::store('file');
    }
}