diff --git a/src/AccessToken.php b/src/AccessToken.php index 0fe2b64f8..a19835e02 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -24,13 +24,18 @@ use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\CachedKeySet; use Google\Auth\Cache\MemoryCacheItemPool; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; +use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use RuntimeException; use stdClass; use UnexpectedValueException; @@ -111,22 +116,28 @@ public function verify($token, array $options = []) $audience = $options['audience'] ?? null; $issuer = $options['issuer'] ?? null; $certsLocation = $options['certsLocation'] ?? self::FEDERATED_SIGNON_CERT_URL; - $cacheKey = $options['cacheKey'] ?? $this->getCacheKeyFromCertLocation($certsLocation); $throwException = $options['throwException'] ?? false; // for backwards compatibility // Check signature against each available cert. - $certs = $this->getCerts($certsLocation, $cacheKey, $options); - try { - $keys = []; - foreach ($certs as $cert) { - if (empty($cert['kid'])) { - throw new InvalidArgumentException('certs expects "kid" to be set'); + $keySet = new CachedKeySet( + $certsLocation, + new class($this->httpHandler) implements ClientInterface { + public function __construct(private $httpHandler) + { } - // create an array of key IDs to certs for the JWT library - $keys[(string) $cert['kid']] = JWK::parseKey($cert); - } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return ($this->httpHandler)($request); + } + }, + new HttpFactory(), + $this->cache + ); + + try { $headers = new stdClass(); - $payload = ($this->jwt)::decode($token, $keys, $headers); + $payload = ($this->jwt)::decode($token, $keySet, $headers); if ($audience) { if (!property_exists($payload, 'aud') || $payload->aud != $audience) { @@ -193,114 +204,4 @@ public function revoke($token, array $options = []) return $response->getStatusCode() == 200; } - - /** - * Gets federated sign-on certificates to use for verifying identity tokens. - * Returns certs as array structure, where keys are key ids, and values - * are PEM encoded certificates. - * - * @param string $location The location from which to retrieve certs. - * @param string $cacheKey The key under which to cache the retrieved certs. - * @param array $options [optional] Configuration options. - * @return array - * @throws InvalidArgumentException If received certs are in an invalid format. - */ - private function getCerts($location, $cacheKey, array $options = []) - { - $cacheItem = $this->cache->getItem($cacheKey); - $certs = $cacheItem ? $cacheItem->get() : null; - - $expireTime = null; - if (!$certs) { - list($certs, $expireTime) = $this->retrieveCertsFromLocation($location, $options); - } - - if (!isset($certs['keys'])) { - if ($location !== self::IAP_CERT_URL) { - throw new InvalidArgumentException( - 'federated sign-on certs expects "keys" to be set' - ); - } - throw new InvalidArgumentException( - 'certs expects "keys" to be set' - ); - } - - // Push caching off until after verifying certs are in a valid format. - // Don't want to cache bad data. - if ($expireTime) { - $cacheItem->expiresAt(new DateTime($expireTime)); - $cacheItem->set($certs); - $this->cache->save($cacheItem); - } - - return $certs['keys']; - } - - /** - * Retrieve and cache a certificates file. - * - * @param string $url location - * @param array $options [optional] Configuration options. - * @return array{array, string} - * @throws InvalidArgumentException If certs could not be retrieved from a local file. - * @throws RuntimeException If certs could not be retrieved from a remote location. - */ - private function retrieveCertsFromLocation($url, array $options = []) - { - // If we're retrieving a local file, just grab it. - $expireTime = '+1 hour'; - if (strpos($url, 'http') !== 0) { - if (!file_exists($url)) { - throw new InvalidArgumentException(sprintf( - 'Failed to retrieve verification certificates from path: %s.', - $url - )); - } - - return [ - json_decode((string) file_get_contents($url), true), - $expireTime - ]; - } - - $httpHandler = $this->httpHandler; - $response = $httpHandler(new Request('GET', $url), $options); - - if ($response->getStatusCode() == 200) { - if ($cacheControl = $response->getHeaderLine('Cache-Control')) { - array_map(function ($value) use (&$expireTime) { - list($key, $value) = explode('=', $value) + [null, null]; - if (trim($key) == 'max-age') { - $expireTime = '+' . $value . ' seconds'; - } - }, explode(',', $cacheControl)); - } - return [ - json_decode((string) $response->getBody(), true), - $expireTime - ]; - } - - throw new RuntimeException(sprintf( - 'Failed to retrieve verification certificates: "%s".', - $response->getBody()->getContents() - ), $response->getStatusCode()); - } - - /** - * Generate a cache key based on the cert location using sha1 with the - * exception of using "federated_signon_certs_v3" to preserve BC. - * - * @param string $certsLocation - * @return string - */ - private function getCacheKeyFromCertLocation($certsLocation) - { - $key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL - ? 'federated_signon_certs_v3' - : sha1($certsLocation); - - return 'google_auth_certs_cache|' . $key; - } } diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index 9c9812012..8642b309d 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -289,35 +289,43 @@ public function testRetrieveCertsFromLocationLocalFile() { $certsLocation = __DIR__ . '/fixtures/federated-certs.json'; $certsData = json_decode(file_get_contents($certsLocation), true); + $kid = null; + foreach ($certsData['keys'] as $i => $cert) { + $certsData[$cert['kid']] = $cert; + $kid = $cert['kid']; + } + unset($certsData['keys']); $item = $this->prophesize('Psr\Cache\CacheItemInterface'); - $item->get() - ->shouldBeCalledTimes(1) - ->willReturn(null); + $item->isHit()->shouldBeCalledTimes(1)->willReturn(false); $item->set($certsData) ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $item->expiresAt(Argument::type('\DateTime')) - ->shouldBeCalledTimes(1) - ->willReturn($item->reveal()); - $this->cache->getItem('google_auth_certs_cache|' . sha1($certsLocation)) + $cacheKey = 'jwks' . preg_replace('|[^a-zA-Z0-9_\.!]|', '', $certsLocation); + $cacheKey = substr(hash('sha256', $cacheKey), 0, 64); + $this->cache->getItem($cacheKey) ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $jwt = new MockJWT(function ($token, $keys, &$headers) { + $jwt = new MockJWT(function ($token, $keys, &$headers) use ($kid) { $this->assertEquals($this->token, $token); - $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + $this->assertArrayHasKey($kid, $keys); + $this->assertEquals('RS256', $keys[$kid]->getAlgorithm()); $headers->alg = 'RS256'; return (object) $this->payload; }); $token = new AccessToken( - null, + function ($request) { + return new Response(200, [ + 'cache-control' => 'public, max-age=1000', + ], file_get_contents((string)$request->getUri())); + }, $this->cache->reveal(), $jwt );