diff --git a/composer.json b/composer.json index 33428c5fa8..6b55311638 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ }, "require": { "php": "^8.0", - "firebase/php-jwt": "^6.0", + "firebase/php-jwt": "^6.10", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.4.5", "psr/http-message": "^1.1||^2.0", @@ -23,14 +23,9 @@ "phpunit/phpunit": "^9.6", "phpspec/prophecy-phpunit": "^2.1", "sebastian/comparator": ">=1.2.3", - "phpseclib/phpseclib": "^3.0.35", - "kelvinmo/simplejwt": "0.7.1", "webmozart/assert": "^1.11", "symfony/process": "^6.0||^7.0" }, - "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 9e27b692ed..7fd686cbba 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -18,7 +18,9 @@ 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; @@ -28,16 +30,9 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; -use phpseclib3\Crypt\PublicKeyLoader; -use phpseclib3\Crypt\RSA; -use phpseclib3\Math\BigInteger; use Psr\Cache\CacheItemPoolInterface; 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 +59,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(); } /** @@ -117,151 +116,51 @@ public function verify($token, array $options = []) // 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' - ); + $keys = []; + foreach ($certs as $cert) { + if (empty($cert['kid'])) { + throw new InvalidArgumentException('certs expects "kid" to be set'); + } + // create an array of key IDs to certs for the JWT library + $keys[(string) $cert['kid']] = JWK::parseKey($cert); } - } - 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(); + $headers = new stdClass(); + $payload = ($this->jwt)::decode($token, $keys, $headers); - $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(); - - 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; } /** @@ -389,72 +288,6 @@ private function retrieveCertsFromLocation($url, array $options = []) ), $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. diff --git a/src/ServiceAccountSignerTrait.php b/src/ServiceAccountSignerTrait.php index b032bf1079..1fc6df4a92 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 de51474b22..9c98120128 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, [ @@ -316,17 +308,19 @@ public function testRetrieveCertsFromLocationLocalFile() $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) { $this->assertEquals($this->token, $token); $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + $headers->alg = 'RS256'; return (object) $this->payload; - }; + }); + + $token = new AccessToken( + null, + $this->cache->reveal(), + $jwt + ); $token->verify($this->token, [ 'certsLocation' => $certsLocation @@ -349,7 +343,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFilePath() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -373,7 +367,7 @@ public function testRetrieveCertsInvalidData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -399,7 +393,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFileData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -446,7 +440,7 @@ public function testRetrieveCertsFromLocationRespectsCacheControl() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -485,17 +479,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); } @@ -520,7 +517,7 @@ public function testRetrieveCertsFromLocationRemoteBadRequest() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -579,25 +576,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(null, (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