httpHandler = $httpHandler ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); $this->cache = $cache ?: new MemoryCacheItemPool(); } /** * Verifies an id token and returns the authenticated apiLoginTicket. * Throws an exception if the id token is not valid. * The audience parameter can be used to control which id tokens are * accepted. By default, the id token must have been issued to this OAuth2 client. * * @param string $token The JSON Web Token to be verified. * @param array $options [optional] { * Configuration options. * @type string $audience The indended recipient of the token. * @type string $issuer The intended issuer of the token. * @type string $cacheKey The cache key of the cached certs. Defaults to * the sha1 of $certsLocation if provided, otherwise is set to * "federated_signon_certs_v3". * @type string $certsLocation The location (remote or local) from which * to retrieve certificates, if not cached. This value should only be * provided in limited circumstances in which you are sure of the * behavior. * @type bool $throwException Whether the function should throw an * exception if the verification fails. This is useful for * determining the reason verification failed. * } * @return array|false the token payload, if successful, or false if not. * @throws InvalidArgumentException If certs could not be retrieved from a local file. * @throws InvalidArgumentException If received certs are in an invalid format. * @throws InvalidArgumentException If the cert alg is not supported. * @throws RuntimeException If certs could not be retrieved from a remote location. * @throws UnexpectedValueException If the token issuer does not match. * @throws UnexpectedValueException If the token audience does not match. */ public function verify($token, array $options = []) { $audience = isset($options['audience']) ? $options['audience'] : null; $issuer = isset($options['issuer']) ? $options['issuer'] : null; $certsLocation = isset($options['certsLocation']) ? $options['certsLocation'] : self::FEDERATED_SIGNON_CERT_URL; $cacheKey = isset($options['cacheKey']) ? $options['cacheKey'] : $this->getCacheKeyFromCertLocation($certsLocation); $throwException = isset($options['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 (DomainException $e) { // @phpstan-ignore-line } 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' ); } } 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' ); } $rsa = new RSA(); $rsa->loadKey([ 'n' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ $cert['n'], ]), 256), 'e' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ $cert['e'] ]), 256), ]); // create an array of key IDs to certs for the JWT library $keys[$cert['kid']] = $rsa->getPublicKey(); } $payload = $this->callJwtStatic('decode', [ $token, $keys, ['RS256'] ]); 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. * * @param string|array $token The token (access token or a refresh token) that should be revoked. * @param array $options [optional] Configuration options. * @return bool Returns True if the revocation was successful, otherwise False. */ public function revoke($token, array $options = []) { if (is_array($token)) { if (isset($token['refresh_token'])) { $token = $token['refresh_token']; } else { $token = $token['access_token']; } } $body = Utils::streamFor(http_build_query(['token' => $token])); $request = new Request('POST', self::OAUTH2_REVOKE_URI, [ 'Cache-Control' => 'no-store', 'Content-Type' => 'application/x-www-form-urlencoded', ], $body); $httpHandler = $this->httpHandler; $response = $httpHandler($request, $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; // @phpstan-ignore-line $gotNewCerts = false; if (!$certs) { $certs = $this->retrieveCertsFromLocation($location, $options); $gotNewCerts = true; } 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 ($gotNewCerts) { $cacheItem->expiresAt(new DateTime('+1 hour')); $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 certificates * @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. 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); } $httpHandler = $this->httpHandler; $response = $httpHandler(new Request('GET', $url), $options); if ($response->getStatusCode() == 200) { return json_decode((string) $response->getBody(), true); } throw new RuntimeException(sprintf( 'Failed to retrieve verification certificates: "%s".', $response->getBody()->getContents() ), $response->getStatusCode()); } /** * @return void */ private function checkAndInitializePhpsec() { // @codeCoverageIgnoreStart if (!class_exists('phpseclib\Crypt\RSA')) { throw new RuntimeException('Please require phpseclib/phpseclib v2 to use this utility.'); } // @codeCoverageIgnoreEnd $this->setPhpsecConstants(); } /** * @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 } /** * phpseclib calls "phpinfo" by default, which requires special * whitelisting in the AppEngine VM environment. This function * sets constants to bypass the need for phpseclib to check phpinfo * * @see phpseclib/Math/BigInteger * @see https://github.com/GoogleCloudPlatform/getting-started-php/issues/85 * @codeCoverageIgnore * * @return void */ private function setPhpsecConstants() { if (filter_var(getenv('GAE_VM'), FILTER_VALIDATE_BOOLEAN)) { if (!defined('MATH_BIGINTEGER_OPENSSL_ENABLED')) { define('MATH_BIGINTEGER_OPENSSL_ENABLED', true); } if (!defined('CRYPT_RSA_MODE')) { define('CRYPT_RSA_MODE', RSA::MODE_OPENSSL); } } } /** * 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; } }