From 8f6c7f2887a2681a9650ec24ff1b93dfeab1ef33 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 30 May 2024 15:28:41 -0700 Subject: [PATCH 1/7] feat: use Firebase JWT for all verification --- composer.json | 7 +- src/AccessToken.php | 217 ++++++++------------------------------ tests/AccessTokenTest.php | 24 +---- 3 files changed, 48 insertions(+), 200 deletions(-) diff --git a/composer.json b/composer.json index 41a1d0532a..f1b583b7b8 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", @@ -22,14 +22,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 630b27961f..9923eaa335 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,15 +30,8 @@ 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 UnexpectedValueException; @@ -124,15 +119,43 @@ public function verify($token, array $options = []) ); } try { - if ($alg == 'RS256') { - return $this->verifyRs256($token, $certs, $audience, $issuer); + $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[$cert['kid']] = JWK::parseKey($cert); } - 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 + + $payload = $this->callJwtStatic('decode', [$token, $keys]); + + if ($audience) { + if (!property_exists($payload, 'aud') || $payload->aud != $audience) { + throw new UnexpectedValueException('Audience does not match'); + } + } + + // support HTTP and HTTPS issuers + // @see https://developers.google.com/identity/sign-in/web/backend-auth + if (is_null($issuer)) { + $issuers = $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 (!isset($payload->iss) || !in_array($payload->iss, $issuers)) { + throw new UnexpectedValueException('Issuer does not match'); + } + + return (array) $payload; + + } catch (ExpiredException $e) { + } catch (SignatureInvalidException $e) { } catch (InvalidArgumentException $e) { } catch (UnexpectedValueException $e) { + } catch (DomainException $e) { } if ($throwException) { @@ -170,100 +193,6 @@ private function determineAlg(array $certs) 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(); - - $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'); - } - } - - // @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' - ); - } - if (empty($cert['n']) || empty($cert['e'])) { - throw new InvalidArgumentException( - 'RSA certs expects "n" and "e" to be set' - ); - } - $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, - ]); - - if ($audience) { - if (!property_exists($payload, 'aud') || $payload->aud != $audience) { - throw new UnexpectedValueException('Audience does not match'); - } - } - - // 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'); - } - - return (array) $payload; - } - /** * Revoke an OAuth2 access token or refresh token. This method will revoke the current access * token, if a token isn't provided. @@ -390,46 +319,19 @@ private function retrieveCertsFromLocation($url, array $options = []) } /** - * @return void - */ - private function checkAndInitializePhpsec() - { - if (!class_exists(RSA::class)) { - throw new RuntimeException('Please require phpseclib/phpseclib v3 to use this utility.'); - } - } - - /** + * 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 - * @throws TypeError If the key cannot be initialized to a string. */ - private function loadPhpsecPublicKey(string $modulus, string $exponent): string + private function getCacheKeyFromCertLocation($certsLocation) { - $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; - } + $key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL + ? 'federated_signon_certs_v3' + : sha1($certsLocation); - /** - * @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 + return 'google_auth_certs_cache|' . $key; } /** @@ -441,33 +343,6 @@ private function checkSimpleJwt() */ 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; + return call_user_func_array([JWT::class, $method], $args); } } diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index de51474b22..47c4c5aebd 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -24,7 +24,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use RuntimeException; -use SimpleJWT\JWT as SimpleJWT; use UnexpectedValueException; /** @@ -239,18 +238,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, [ @@ -589,15 +577,5 @@ protected function callJwtStatic($method, array $args = []) ? call_user_func_array($this->mocks[$method], $args) : parent::callJwtStatic($method, $args); } - - 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); - } - - return parent::callSimpleJwtDecode($args); - } } //@codingStandardsIgnoreEnd From 7fedf52553894e910bbace97dff0147303cda711 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 30 May 2024 15:46:41 -0700 Subject: [PATCH 2/7] remove determineAlg --- src/AccessToken.php | 50 ++++++++------------------------------- tests/AccessTokenTest.php | 28 +++++++++++++++------- 2 files changed, 29 insertions(+), 49 deletions(-) diff --git a/src/AccessToken.php b/src/AccessToken.php index 9923eaa335..4f55c19974 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -32,6 +32,7 @@ use InvalidArgumentException; use Psr\Cache\CacheItemPoolInterface; use RuntimeException; +use stdClass; use TypeError; use UnexpectedValueException; @@ -112,12 +113,6 @@ 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 { $keys = []; foreach ($certs as $cert) { @@ -127,8 +122,8 @@ public function verify($token, array $options = []) // create an array of key IDs to certs for the JWT library $keys[$cert['kid']] = JWK::parseKey($cert); } - - $payload = $this->callJwtStatic('decode', [$token, $keys]); + $headers = new \stdClass(); + $payload = $this->callJwtDecode($token, $keys, $headers); if ($audience) { if (!property_exists($payload, 'aud') || $payload->aud != $audience) { @@ -139,7 +134,7 @@ public function verify($token, array $options = []) // support HTTP and HTTPS issuers // @see https://developers.google.com/identity/sign-in/web/backend-auth if (is_null($issuer)) { - $issuers = $alg == 'RS256' + $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 { @@ -165,34 +160,6 @@ public function verify($token, array $options = []) 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' - ); - } - } - return $alg; - } - /** * Revoke an OAuth2 access token or refresh token. This method will revoke the current access * token, if a token isn't provided. @@ -337,12 +304,15 @@ private function getCacheKeyFromCertLocation($certsLocation) /** * 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 = []) + protected function callJwtDecode( + string $jwt, + $keyOrKeyArray, + stdClass &$headers = null + ): stdClass { - return call_user_func_array([JWT::class, $method], $args); + return JWT::decode($jwt, $keyOrKeyArray, $headers); } } diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index 47c4c5aebd..edf35849eb 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -25,6 +25,7 @@ use Psr\Http\Message\RequestInterface; use RuntimeException; use UnexpectedValueException; +use stdClass; /** * @group access-token @@ -91,8 +92,9 @@ public function testVerify( $this->cache->reveal() ); - $token->mocks['decode'] = function ($token, $keys) use ($payload, $exception) { + $token->mockDecode = function ($token, $keys, &$headers) use ($payload, $exception) { $this->assertEquals($this->token, $token); + $headers->alg = array_pop($keys)->getAlgorithm(); if ($exception) { throw $exception; @@ -309,9 +311,10 @@ public function testRetrieveCertsFromLocationLocalFile() $this->cache->reveal() ); - $token->mocks['decode'] = function ($token, $keys) { + $token->mockDecode = function ($token, $keys, &$headers) { $this->assertEquals($this->token, $token); $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + $headers->alg = 'RS256'; return (object) $this->payload; }; @@ -434,7 +437,7 @@ public function testRetrieveCertsFromLocationRespectsCacheControl() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -478,9 +481,10 @@ public function testRetrieveCertsFromLocationRemote() $this->cache->reveal() ); - $token->mocks['decode'] = function ($token, $keys) { + $token->mockDecode = function ($token, $keys, &$headers) { $this->assertEquals($this->token, $token); $this->assertEquals('RS256', array_pop($keys)->getAlgorithm()); + $headers->alg = 'RS256'; return (object) $this->payload; }; @@ -569,13 +573,19 @@ public function testRevokeFails() //@codingStandardsIgnoreStart class AccessTokenStub extends AccessToken { - public $mocks = []; + public $mockDecode = null; - protected function callJwtStatic($method, array $args = []) + protected function callJwtDecode( + string $jwt, + $keyOrKeyArray, + stdClass &$headers = null + ): stdClass { - return isset($this->mocks[$method]) - ? call_user_func_array($this->mocks[$method], $args) - : parent::callJwtStatic($method, $args); + if (!isset($this->mockDecode)) { + throw new RuntimeException('mockDecode not set'); + } + + return ($this->mockDecode)($jwt, $keyOrKeyArray, $headers); } } //@codingStandardsIgnoreEnd From 57fdc6452624978c09f3aec1cb2b7045dd8ebff1 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 3 Jun 2024 10:16:53 -0700 Subject: [PATCH 3/7] remove callJwtDecode, fix tests --- src/AccessToken.php | 25 +++-------- src/ServiceAccountSignerTrait.php | 2 +- tests/AccessTokenTest.php | 74 ++++++++++++++++++------------- 3 files changed, 50 insertions(+), 51 deletions(-) diff --git a/src/AccessToken.php b/src/AccessToken.php index 4f55c19974..a46a4826ae 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -60,17 +60,21 @@ class AccessToken */ private $cache; + private JWT $jwt; + /** * @param callable $httpHandler [optional] An HTTP Handler to deliver PSR-7 requests. * @param CacheItemPoolInterface $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(); } /** @@ -120,10 +124,10 @@ public function verify($token, array $options = []) throw new InvalidArgumentException('certs expects "kid" to be set'); } // create an array of key IDs to certs for the JWT library - $keys[$cert['kid']] = JWK::parseKey($cert); + $keys[(string) $cert['kid']] = JWK::parseKey($cert); } $headers = new \stdClass(); - $payload = $this->callJwtDecode($token, $keys, $headers); + $payload = ($this->jwt)::decode($token, $keys, $headers); if ($audience) { if (!property_exists($payload, 'aud') || $payload->aud != $audience) { @@ -300,19 +304,4 @@ private function getCacheKeyFromCertLocation($certsLocation) return 'google_auth_certs_cache|' . $key; } - - /** - * Provide a hook to mock calls to the JWT static methods. - * - * @param array $args - * @return mixed - */ - protected function callJwtDecode( - string $jwt, - $keyOrKeyArray, - stdClass &$headers = null - ): stdClass - { - return JWT::decode($jwt, $keyOrKeyArray, $headers); - } } 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 edf35849eb..c26920e876 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,8 +25,8 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use RuntimeException; -use UnexpectedValueException; use stdClass; +use UnexpectedValueException; /** * @group access-token @@ -87,12 +88,7 @@ public function testVerify( ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( - null, - $this->cache->reveal() - ); - - $token->mockDecode = function ($token, $keys, &$headers) use ($payload, $exception) { + $jwt = new MockJWT(function ($token, $keys, &$headers) use ($payload, $exception) { $this->assertEquals($this->token, $token); $headers->alg = array_pop($keys)->getAlgorithm(); @@ -101,7 +97,13 @@ public function testVerify( } return (object) $payload; - }; + }); + + $token = new AccessToken( + null, + $this->cache->reveal(), + $jwt + ); $e = null; $res = false; @@ -306,18 +308,19 @@ public function testRetrieveCertsFromLocationLocalFile() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( - null, - $this->cache->reveal() - ); - - $token->mockDecode = function ($token, $keys, &$headers) { + $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 @@ -340,7 +343,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFilePath() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -364,7 +367,7 @@ public function testRetrieveCertsInvalidData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -390,7 +393,7 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFileData() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( null, $this->cache->reveal() ); @@ -476,18 +479,20 @@ public function testRetrieveCertsFromLocationRemote() $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) ->shouldBeCalledTimes(1); - $token = new AccessTokenStub( - $httpHandler, - $this->cache->reveal() - ); - - $token->mockDecode = function ($token, $keys, &$headers) { + $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); } @@ -512,7 +517,7 @@ public function testRetrieveCertsFromLocationRemoteBadRequest() ->shouldBeCalledTimes(1) ->willReturn($item->reveal()); - $token = new AccessTokenStub( + $token = new AccessToken( $httpHandler, $this->cache->reveal() ); @@ -571,21 +576,26 @@ public function testRevokeFails() } //@codingStandardsIgnoreStart -class AccessTokenStub extends AccessToken +class MockJWT extends JWT { - public $mockDecode = null; + private static $mockDecode; + + public function __construct($mockDecode) + { + self::$mockDecode = $mockDecode; + } - protected function callJwtDecode( + public static function decode( string $jwt, $keyOrKeyArray, stdClass &$headers = null ): stdClass { - if (!isset($this->mockDecode)) { + if (!isset(self::$mockDecode)) { throw new RuntimeException('mockDecode not set'); } - return ($this->mockDecode)($jwt, $keyOrKeyArray, $headers); + return (self::$mockDecode)($jwt, $keyOrKeyArray, $headers); } } -//@codingStandardsIgnoreEnd +//@codingStandardsIgnoreEnd \ No newline at end of file From e4f6e47e9ef9f05ac80a07e30c1205c236e96195 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 3 Jun 2024 10:27:04 -0700 Subject: [PATCH 4/7] fix style --- tests/AccessTokenTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index c26920e876..4bc0937b8c 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -589,8 +589,7 @@ public static function decode( string $jwt, $keyOrKeyArray, stdClass &$headers = null - ): stdClass - { + ): stdClass { if (!isset(self::$mockDecode)) { throw new RuntimeException('mockDecode not set'); } From f53909da11c4564ecf041ff521f3a052af6ba398 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 3 Jun 2024 10:30:03 -0700 Subject: [PATCH 5/7] add newline -_- --- tests/AccessTokenTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index 4bc0937b8c..9c98120128 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -597,4 +597,4 @@ public static function decode( return (self::$mockDecode)($jwt, $keyOrKeyArray, $headers); } } -//@codingStandardsIgnoreEnd \ No newline at end of file +//@codingStandardsIgnoreEnd From 035a6a07407ba887d894a79db45ada38e44dac1b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 6 Jun 2024 10:39:40 -0700 Subject: [PATCH 6/7] remove unused imports --- src/AccessToken.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AccessToken.php b/src/AccessToken.php index a46a4826ae..0fe2b64f8f 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -33,7 +33,6 @@ use Psr\Cache\CacheItemPoolInterface; use RuntimeException; use stdClass; -use TypeError; use UnexpectedValueException; /** @@ -126,7 +125,7 @@ public function verify($token, array $options = []) // create an array of key IDs to certs for the JWT library $keys[(string) $cert['kid']] = JWK::parseKey($cert); } - $headers = new \stdClass(); + $headers = new stdClass(); $payload = ($this->jwt)::decode($token, $keys, $headers); if ($audience) { From 47967e1735c091299e298336f84f3a6572815604 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 9 Mar 2026 16:48:49 -0700 Subject: [PATCH 7/7] feat: use cachedkeyset in AccessToken class (#561) --- src/AccessToken.php | 162 ++++++++++---------------------------- tests/AccessTokenTest.php | 22 +++--- 2 files changed, 53 insertions(+), 131 deletions(-) diff --git a/src/AccessToken.php b/src/AccessToken.php index 7fd686cbba..db8bb47fb3 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -24,13 +24,19 @@ 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 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 +117,44 @@ 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'); - } - // create an array of key IDs to certs for the JWT library - $keys[(string) $cert['kid']] = JWK::parseKey($cert); + // 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 + )); } + + $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 + ); + + 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 +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()); - } - - /** - * 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 9c98120128..19c73d4e33 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -289,28 +289,32 @@ 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;