diff --git a/composer.json b/composer.json index 84c82d37e..c084a1e70 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ }, "require": { "php": "^8.1", - "firebase/php-jwt": "^6.0||^7.0", + "firebase/php-jwt": "^6.10||^7.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.4.5", "psr/http-message": "^1.1||^2.0", @@ -29,9 +29,6 @@ "symfony/process": "^6.0||^7.0", "symfony/filesystem": "^6.3||^7.3" }, - "suggest": { - "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." - }, "autoload": { "psr-4": { "Google\\Auth\\": "src" diff --git a/src/AccessToken.php b/src/AccessToken.php index 9e27b692e..db8bb47fb 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -18,26 +18,27 @@ namespace Google\Auth; use DateTime; +use DomainException; use Firebase\JWT\ExpiredException; +use Firebase\JWT\JWK; 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\Response; use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; -use phpseclib3\Crypt\PublicKeyLoader; -use phpseclib3\Crypt\RSA; -use phpseclib3\Math\BigInteger; use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use RuntimeException; -use SimpleJWT\InvalidTokenException; -use SimpleJWT\JWT as SimpleJWT; -use SimpleJWT\Keys\KeyFactory; -use SimpleJWT\Keys\KeySet; -use TypeError; +use stdClass; use UnexpectedValueException; /** @@ -64,17 +65,21 @@ class AccessToken */ private $cache; + private JWT $jwt; + /** * @param callable|null $httpHandler [optional] An HTTP Handler to deliver PSR-7 requests. * @param CacheItemPoolInterface|null $cache [optional] A PSR-6 compatible cache implementation. */ public function __construct( ?callable $httpHandler = null, - ?CacheItemPoolInterface $cache = null + ?CacheItemPoolInterface $cache = null, + JWT $jwt = null ) { $this->httpHandler = $httpHandler ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); $this->cache = $cache ?: new MemoryCacheItemPool(); + $this->jwt = $jwt ?: new JWT(); } /** @@ -112,156 +117,78 @@ 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); - $alg = $this->determineAlg($certs); - if (!in_array($alg, ['RS256', 'ES256'])) { - throw new InvalidArgumentException( - 'unrecognized "alg" in certs, expected ES256 or RS256' - ); - } - try { - if ($alg == 'RS256') { - return $this->verifyRs256($token, $certs, $audience, $issuer); - } - return $this->verifyEs256($token, $certs, $audience, $issuer); - } catch (ExpiredException $e) { // firebase/php-jwt 5+ - } catch (SignatureInvalidException $e) { // firebase/php-jwt 5+ - } catch (InvalidTokenException $e) { // simplejwt - } catch (InvalidArgumentException $e) { - } catch (UnexpectedValueException $e) { - } - - if ($throwException) { - throw $e; - } - - return false; - } - - /** - * Identifies the expected algorithm to verify by looking at the "alg" key - * of the provided certs. - * - * @param array $certs Certificate array according to the JWK spec (see - * https://tools.ietf.org/html/rfc7517). - * @return string The expected algorithm, such as "ES256" or "RS256". - */ - private function determineAlg(array $certs) - { - $alg = null; - foreach ($certs as $cert) { - if (empty($cert['alg'])) { - throw new InvalidArgumentException( - 'certs expects "alg" to be set' - ); - } - $alg = $alg ?: $cert['alg']; - - if ($alg != $cert['alg']) { - throw new InvalidArgumentException( - 'More than one alg detected in certs' - ); + // If we're retrieving a local file, just grab it. + $httpHandler = null; + if (strpos($certsLocation, 'http') !== 0) { + if (!file_exists($certsLocation)) { + throw new InvalidArgumentException(sprintf( + 'Failed to retrieve verification certificates from path: %s.', + $certsLocation + )); } - } - return $alg; - } - /** - * Verifies an ES256-signed JWT. - * - * @param string $token The JSON Web Token to be verified. - * @param array $certs Certificate array according to the JWK spec (see - * https://tools.ietf.org/html/rfc7517). - * @param string|null $audience If set, returns false if the provided - * audience does not match the "aud" claim on the JWT. - * @param string|null $issuer If set, returns false if the provided - * issuer does not match the "iss" claim on the JWT. - * @return array the token payload, if successful, or false if not. - */ - private function verifyEs256($token, array $certs, $audience = null, $issuer = null) - { - $this->checkSimpleJwt(); + $httpHandler = function () use ($certsLocation) { + return new Response(200, [ + 'cache-control' => 'public, max-age=1000', + ], file_get_contents($certsLocation)); + }; + } + + $keySet = new CachedKeySet( + $certsLocation, + new class($httpHandler ?: $this->httpHandler) implements ClientInterface { + public function __construct(private $httpHandler) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return ($this->httpHandler)($request); + } + }, + new HttpFactory(), + $this->cache + ); - $jwkset = new KeySet(); - foreach ($certs as $cert) { - $jwkset->add(KeyFactory::create($cert, 'php')); - } - - // Validate the signature using the key set and ES256 algorithm. - $jwt = $this->callSimpleJwtDecode([$token, $jwkset, 'ES256']); - $payload = $jwt->getClaims(); + try { + $headers = new stdClass(); + $payload = ($this->jwt)::decode($token, $keySet, $headers); - if ($audience) { - if (!isset($payload['aud']) || $payload['aud'] != $audience) { - throw new UnexpectedValueException('Audience does not match'); + if ($audience) { + if (!property_exists($payload, 'aud') || $payload->aud != $audience) { + throw new UnexpectedValueException('Audience does not match'); + } } - } - - // @see https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload - $issuer = $issuer ?: self::IAP_ISSUER; - if (!isset($payload['iss']) || $payload['iss'] !== $issuer) { - throw new UnexpectedValueException('Issuer does not match'); - } - - return $payload; - } - /** - * Verifies an RS256-signed JWT. - * - * @param string $token The JSON Web Token to be verified. - * @param array $certs Certificate array according to the JWK spec (see - * https://tools.ietf.org/html/rfc7517). - * @param string|null $audience If set, returns false if the provided - * audience does not match the "aud" claim on the JWT. - * @param string|null $issuer If set, returns false if the provided - * issuer does not match the "iss" claim on the JWT. - * @return array the token payload, if successful, or false if not. - */ - private function verifyRs256($token, array $certs, $audience = null, $issuer = null) - { - $this->checkAndInitializePhpsec(); - $keys = []; - foreach ($certs as $cert) { - if (empty($cert['kid'])) { - throw new InvalidArgumentException( - 'certs expects "kid" to be set' - ); + // support HTTP and HTTPS issuers + // @see https://developers.google.com/identity/sign-in/web/backend-auth + if (is_null($issuer)) { + $issuers = $headers->alg == 'RS256' + ? [self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS] // default to OAuth2 for RS256 + : [self::IAP_ISSUER]; // default to IAP for ES256 + } else { + $issuers = [$issuer]; } - if (empty($cert['n']) || empty($cert['e'])) { - throw new InvalidArgumentException( - 'RSA certs expects "n" and "e" to be set' - ); + if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) { + throw new UnexpectedValueException('Issuer does not match'); } - $publicKey = $this->loadPhpsecPublicKey($cert['n'], $cert['e']); - - // create an array of key IDs to certs for the JWT library - $keys[$cert['kid']] = new Key($publicKey, 'RS256'); - } - $payload = $this->callJwtStatic('decode', [ - $token, - $keys, - ]); + return (array) $payload; - if ($audience) { - if (!property_exists($payload, 'aud') || $payload->aud != $audience) { - throw new UnexpectedValueException('Audience does not match'); - } + } catch (ExpiredException $e) { + } catch (SignatureInvalidException $e) { + } catch (InvalidArgumentException $e) { + } catch (UnexpectedValueException $e) { + } catch (DomainException $e) { } - // support HTTP and HTTPS issuers - // @see https://developers.google.com/identity/sign-in/web/backend-auth - $issuers = $issuer ? [$issuer] : [self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS]; - if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) { - throw new UnexpectedValueException('Issuer does not match'); + if ($throwException) { + throw $e; } - return (array) $payload; + return false; } /** @@ -294,180 +221,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()); - } - - /** - * @return void - */ - private function checkAndInitializePhpsec() - { - if (!class_exists(RSA::class)) { - throw new RuntimeException('Please require phpseclib/phpseclib v3 to use this utility.'); - } - } - - /** - * @return string - * @throws TypeError If the key cannot be initialized to a string. - */ - private function loadPhpsecPublicKey(string $modulus, string $exponent): string - { - $key = PublicKeyLoader::load([ - 'n' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ - $modulus, - ]), 256), - 'e' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ - $exponent - ]), 256), - ]); - $formattedPublicKey = $key->toString('PKCS8'); - if (!is_string($formattedPublicKey)) { - throw new TypeError('Failed to initialize the key'); - } - return $formattedPublicKey; - } - - /** - * @return void - */ - private function checkSimpleJwt() - { - // @codeCoverageIgnoreStart - if (!class_exists(SimpleJwt::class)) { - throw new RuntimeException('Please require kelvinmo/simplejwt ^0.2 to use this utility.'); - } - // @codeCoverageIgnoreEnd - } - - /** - * Provide a hook to mock calls to the JWT static methods. - * - * @param string $method - * @param array $args - * @return mixed - */ - protected function callJwtStatic($method, array $args = []) - { - return call_user_func_array([JWT::class, $method], $args); // @phpstan-ignore-line - } - - /** - * Provide a hook to mock calls to the JWT static methods. - * - * @param array $args - * @return mixed - */ - protected function callSimpleJwtDecode(array $args = []) - { - return call_user_func_array([SimpleJwt::class, 'decode'], $args); - } - - /** - * 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/src/ServiceAccountSignerTrait.php b/src/ServiceAccountSignerTrait.php index b032bf107..1fc6df4a9 100644 --- a/src/ServiceAccountSignerTrait.php +++ b/src/ServiceAccountSignerTrait.php @@ -38,7 +38,7 @@ public function signBlob($stringToSign, $forceOpenssl = false) $privateKey = $this->auth->getSigningKey(); $signedString = ''; - if (class_exists(phpseclib3\Crypt\RSA::class) && !$forceOpenssl) { + if (class_exists(PublicKeyLoader::class) && class_exists(RSA::class) && !$forceOpenssl) { $key = PublicKeyLoader::load($privateKey); $rsa = $key->withHash('sha256')->withPadding(RSA::SIGNATURE_PKCS1); diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index d18f5c38a..4744e54e2 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -16,6 +16,7 @@ */ namespace Google\Auth\Tests; +use Firebase\JWT\JWT; use Google\Auth\AccessToken; use GuzzleHttp\Psr7\Response; use InvalidArgumentException; @@ -24,7 +25,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use RuntimeException; -use SimpleJWT\JWT as SimpleJWT; +use stdClass; use UnexpectedValueException; /** @@ -87,20 +88,22 @@ public function testVerify( ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( - null, - $this->cache->reveal() - ); - - $token->mocks['decode'] = function ($token, $keys) use ($payload, $exception) { + $jwt = new MockJWT(function ($token, $keys, &$headers) use ($payload, $exception) { $this->assertEquals($this->token, $token); + $headers->alg = array_pop($keys)->getAlgorithm(); if ($exception) { throw $exception; } return (object) $payload; - }; + }); + + $token = new AccessToken( + null, + $this->cache->reveal(), + $jwt + ); $e = null; $res = false; @@ -239,18 +242,7 @@ public function testEsVerifyEndToEnd() $this->markTestSkipped('Set the IAP_IDENTITY_TOKEN env var'); } - $token = new AccessTokenStub(); - $token->mocks['decode'] = function ($token, $publicKey, $allowedAlgs) { - // Skip expired validation - $jwt = SimpleJWT::decode( - $token, - $publicKey, - $allowedAlgs, - null, - ['exp'] - ); - return $jwt->getClaims(); - }; + $token = new AccessToken(); // Use Iap Cert URL $payload = $token->verify($jwt, [ @@ -295,36 +287,42 @@ public function testRetrieveCertsFromLocationLocalFile() { $certsLocation = __DIR__ . '/fixtures/fixtures1/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); - $token = new AccessTokenStub( - null, - $this->cache->reveal() - ); - - $token->mocks['decode'] = function ($token, $keys) { + $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, + $this->cache->reveal(), + $jwt + ); $token->verify($this->token, [ 'certsLocation' => $certsLocation @@ -347,7 +345,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFilePath() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -371,7 +369,7 @@ public function testRetrieveCertsInvalidData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -397,7 +395,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFileData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -444,7 +442,7 @@ public function testRetrieveCertsFromLocationRespectsCacheControl() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -483,17 +481,20 @@ public function testRetrieveCertsFromLocationRemote() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( - $httpHandler, - $this->cache->reveal() - ); - - $token->mocks['decode'] = function ($token, $keys) { + $jwt = new MockJWT(function ($token, $keys, &$headers) { $this->assertEquals($this->token, $token); $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + $headers->alg = 'RS256'; return (object) $this->payload; - }; + }); + + $token = new AccessToken( + $httpHandler, + $this->cache->reveal(), + $jwt + ); + $token->verify($this->token); } @@ -518,7 +519,7 @@ public function testRetrieveCertsFromLocationRemoteBadRequest() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -577,25 +578,25 @@ public function testRevokeFails() } //@codingStandardsIgnoreStart -class AccessTokenStub extends AccessToken +class MockJWT extends JWT { - public $mocks = []; + private static $mockDecode; - protected function callJwtStatic($method, array $args = []) + public function __construct($mockDecode) { - return isset($this->mocks[$method]) - ? call_user_func_array($this->mocks[$method], $args) - : parent::callJwtStatic($method, $args); + self::$mockDecode = $mockDecode; } - protected function callSimpleJwtDecode(array $args = []) - { - if (isset($this->mocks['decode'])) { - $claims = call_user_func_array($this->mocks['decode'], $args); - return new SimpleJWT([], (array) $claims); + public static function decode( + string $jwt, + $keyOrKeyArray, + stdClass &$headers = null + ): stdClass { + if (!isset(self::$mockDecode)) { + throw new RuntimeException('mockDecode not set'); } - return parent::callSimpleJwtDecode($args); + return (self::$mockDecode)($jwt, $keyOrKeyArray, $headers); } } //@codingStandardsIgnoreEnd