diff --git a/composer.json b/composer.json index 974c1f274a..aaec224812 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ }, "require": { "php": "^7.1||^8.0", - "firebase/php-jwt": "~5.0", + "firebase/php-jwt": "^5.5||^6.0", "guzzlehttp/guzzle": "^6.2.1|^7.0", "guzzlehttp/psr7": "^1.7|^2.0", "psr/http-message": "^1.0", diff --git a/src/OAuth2.php b/src/OAuth2.php index e9404412f3..76075bdef7 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -18,6 +18,7 @@ namespace Google\Auth; use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; use GuzzleHttp\Psr7\Query; @@ -380,17 +381,18 @@ public function __construct(array $config) * - otherwise returns the payload in the idtoken as a PHP object. * * The behavior of this method varies depending on the version of - * `firebase/php-jwt` you are using. In versions lower than 3.0.0, if - * `$publicKey` is null, the key is decoded without being verified. In - * newer versions, if a public key is not given, this method will throw an - * `\InvalidArgumentException`. + * `firebase/php-jwt` you are using. In versions 6.0 and above, you cannot + * provide multiple $allowed_algs, and instead must provide an array of Key + * objects as the $publicKey. * - * @param string $publicKey The public key to use to authenticate the token - * @param array $allowed_algs List of supported verification algorithms + * @param string|Key|Key[] $publicKey The public key to use to authenticate the token + * @param string|array $allowed_algs algorithm or array of supported verification algorithms. + * Providing more than one algorithm will throw an exception. * @throws \DomainException if the token is missing an audience. * @throws \DomainException if the audience does not match the one set in * the OAuth2 class instance. * @throws \UnexpectedValueException If the token is invalid + * @throws \InvalidArgumentException If more than one value for allowed_algs is supplied * @throws \Firebase\JWT\SignatureInvalidException If the signature is invalid. * @throws \Firebase\JWT\BeforeValidException If the token is not yet valid. * @throws \Firebase\JWT\ExpiredException If the token has expired. @@ -461,7 +463,7 @@ public function toJwt(array $config = []) } $assertion += $this->getAdditionalClaims(); - return $this->jwtEncode( + return JWT::encode( $assertion, $this->getSigningKey(), $this->getSigningAlgorithm(), @@ -1441,30 +1443,86 @@ private function coerceUri($uri) /** * @param string $idToken - * @param string|array|null $publicKey - * @param array $allowedAlgs + * @param Key|Key[]|string|string[] $publicKey + * @param string|string[] $allowedAlgs * @return object */ private function jwtDecode($idToken, $publicKey, $allowedAlgs) { - return JWT::decode($idToken, $publicKey, $allowedAlgs); + $keys = $this->getFirebaseJwtKeys($publicKey, $allowedAlgs); + + // Default exception if none are caught. We are using the same exception + // class and message from firebase/php-jwt to preserve backwards + // compatibility. + $e = new \InvalidArgumentException('Key may not be empty'); + foreach ($keys as $key) { + try { + return JWT::decode($idToken, $key); + } catch (\Exception $e) { + // try next alg + } + } + throw $e; } /** - * @param array $assertion - * @param string $signingKey - * @param string $signingAlgorithm - * @param string $signingKeyId - * @return string + * @param Key|Key[]|string|string[] $publicKey + * @param string|string[] $allowedAlgs + * @return Key[] */ - private function jwtEncode($assertion, $signingKey, $signingAlgorithm, $signingKeyId = null) + private function getFirebaseJwtKeys($publicKey, $allowedAlgs) { - return JWT::encode( - $assertion, - $signingKey, - $signingAlgorithm, - $signingKeyId - ); + // If $publicKey is instance of Key, return it + if ($publicKey instanceof Key) { + return [$publicKey]; + } + + // If $allowedAlgs is empty, $publicKey must be Key or Key[]. + if (empty($allowedAlgs)) { + $keys = []; + foreach ((array) $publicKey as $kid => $pubKey) { + if (!$pubKey instanceof Key) { + throw new \InvalidArgumentException(sprintf( + 'When allowed algorithms is empty, the public key must' + . 'be an instance of %s or an array of %s objects', + Key::class, + Key::class + )); + } + $keys[$kid] = $pubKey; + } + return $keys; + } + + $allowedAlg = null; + if (is_string($allowedAlgs)) { + $allowedAlg = $allowedAlg; + } elseif (is_array($allowedAlgs)) { + if (count($allowedAlgs) > 1) { + throw new \InvalidArgumentException( + 'To have multiple allowed algorithms, You must provide an' + . ' array of Firebase\JWT\Key objects.' + . ' See https://github.com/firebase/php-jwt for more information.'); + } + $allowedAlg = array_pop($allowedAlgs); + } else { + throw new \InvalidArgumentException('allowed algorithms must be a string or array.'); + } + + if (is_array($publicKey)) { + // When publicKey is greater than 1, create keys with the single alg. + $keys = []; + foreach ($publicKey as $kid => $pubKey) { + if ($pubKey instanceof Key) { + $keys[$kid] = $pubKey; + } else { + $keys[$kid] = new Key($pubKey, $allowedAlg); + } + } + return $keys; + } + + return [new Key($publicKey, $allowedAlg)]; } /** diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 9546ee5d7e..e3e9368bea 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -19,6 +19,7 @@ use DomainException; use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountJwtAccessCredentials; @@ -799,8 +800,7 @@ public function testJwtAccessFromApplicationDefault() $this->assertArrayHasKey('authorization', $metadata); $token = str_replace('Bearer ', '', $metadata['authorization'][0]); $key = file_get_contents(__DIR__ . '/../fixtures3/key.pub'); - - $result = JWT::decode($token, $key, ['RS256']); + $result = JWT::decode($token, new Key($key, 'RS256')); $this->assertEquals($authUri, $result->aud); } diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index 5b9719279f..6a08471fb3 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -19,6 +19,7 @@ use DomainException; use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Google\Auth\OAuth2; use GuzzleHttp\Psr7\Query; use GuzzleHttp\Psr7\Utils; @@ -442,15 +443,15 @@ public function testFailsWithMissingSigningAlgorithm() public function testCanHS256EncodeAValidPayloadWithSigningKeyId() { $testConfig = $this->signingMinimal; - $keys = array( - 'example_key_id1' => 'example_key1', - 'example_key_id2' => 'example_key2' - ); - $testConfig['signingKey'] = $keys['example_key_id2']; + $keys = [ + 'example_key_id1' => new Key('example_key1', 'HS256'), + 'example_key_id2' => new Key('example_key2', 'HS256'), + ]; + $testConfig['signingKey'] = $keys['example_key_id2']->getKeyMaterial(); $testConfig['signingKeyId'] = 'example_key_id2'; $o = new OAuth2($testConfig); $payload = $o->toJwt(); - $roundTrip = JWT::decode($payload, $keys, array('HS256')); + $roundTrip = JWT::decode($payload, $keys); $this->assertEquals($roundTrip->iss, $testConfig['issuer']); $this->assertEquals($roundTrip->aud, $testConfig['audience']); $this->assertEquals($roundTrip->scope, $testConfig['scope']); @@ -459,16 +460,16 @@ public function testCanHS256EncodeAValidPayloadWithSigningKeyId() public function testFailDecodeWithoutSigningKeyId() { $testConfig = $this->signingMinimal; - $keys = array( - 'example_key_id1' => 'example_key1', - 'example_key_id2' => 'example_key2' - ); - $testConfig['signingKey'] = $keys['example_key_id2']; + $keys = [ + 'example_key_id1' => new Key('example_key1', 'HS256'), + 'example_key_id2' => new Key('example_key2', 'HS256'), + ]; + $testConfig['signingKey'] = $keys['example_key_id2']->getKeyMaterial(); $o = new OAuth2($testConfig); $payload = $o->toJwt(); try { - JWT::decode($payload, $keys, array('HS256')); + JWT::decode($payload, $keys); } catch (\Exception $e) { // Workaround: In old JWT versions throws DomainException $this->assertTrue( @@ -485,7 +486,7 @@ public function testCanHS256EncodeAValidPayload() $testConfig = $this->signingMinimal; $o = new OAuth2($testConfig); $payload = $o->toJwt(); - $roundTrip = JWT::decode($payload, $testConfig['signingKey'], array('HS256')); + $roundTrip = JWT::decode($payload, new Key($testConfig['signingKey'], 'HS256')); $this->assertEquals($roundTrip->iss, $testConfig['issuer']); $this->assertEquals($roundTrip->aud, $testConfig['audience']); $this->assertEquals($roundTrip->scope, $testConfig['scope']); @@ -500,7 +501,7 @@ public function testCanRS256EncodeAValidPayload() $o->setSigningAlgorithm('RS256'); $o->setSigningKey($privateKey); $payload = $o->toJwt(); - $roundTrip = JWT::decode($payload, $publicKey, array('RS256')); + $roundTrip = JWT::decode($payload, new Key($publicKey, 'RS256')); $this->assertEquals($roundTrip->iss, $testConfig['issuer']); $this->assertEquals($roundTrip->aud, $testConfig['audience']); $this->assertEquals($roundTrip->scope, $testConfig['scope']); @@ -517,7 +518,7 @@ public function testCanHaveAdditionalClaims() $o->setSigningAlgorithm('RS256'); $o->setSigningKey($privateKey); $payload = $o->toJwt(); - $roundTrip = JWT::decode($payload, $publicKey, array('RS256')); + $roundTrip = JWT::decode($payload, new Key($publicKey, 'RS256')); $this->assertEquals($roundTrip->target_audience, $targetAud); } } @@ -907,7 +908,7 @@ public function testFailsIfIdTokenIsInvalid() $not_a_jwt = 'not a jot'; $o = new OAuth2($testConfig); $o->setIdToken($not_a_jwt); - $o->verifyIdToken($this->publicKey); + $o->verifyIdToken($this->publicKey, ['RS256']); } public function testFailsIfAudienceIsMissing() @@ -943,6 +944,57 @@ public function testFailsIfAudienceIsWrong() $o->verifyIdToken($this->publicKey, ['RS256']); } + public function testFailsWithStringPublicKeyAndAllowedAlgsGreaterThanOne() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('To have multiple allowed algorithms'); + + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($this->publicKey, ['RS256', 'ES256']); + } + + public function testFailsWithStringPublicKeyAndNoAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('When allowed algorithms is empty'); + + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($this->publicKey, []); + } + + public function testFailsWithStringInPublicKeyArrayAndNoAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('When allowed algorithms is empty'); + + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken([ + new Key($this->publicKey, 'RS256'), + $this->publicKey, + ], []); + } + + public function testFailsWithInvalidTypeForAllowedAlgs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('allowed algorithms must be a string or array'); + + $testConfig = $this->verifyIdTokenMinimal; + $not_a_jwt = 'not a jot'; + $o = new OAuth2($testConfig); + $o->setIdToken($not_a_jwt); + $o->verifyIdToken($this->publicKey, 123); + } + public function testShouldReturnAValidIdToken() { $testConfig = $this->verifyIdTokenMinimal;