Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use Firebase JWT for token verification #554

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
247 changes: 40 additions & 207 deletions src/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand All @@ -64,17 +59,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();
}

/**
Expand Down Expand Up @@ -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<mixed> $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<mixed> $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<mixed> 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<mixed> $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<mixed> 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;
}

/**
Expand Down Expand Up @@ -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<mixed> $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<mixed> $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.
Expand Down
2 changes: 1 addition & 1 deletion src/ServiceAccountSignerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading