1<?php 2/* 3 * Copyright 2019 Google LLC 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18namespace Google\Auth; 19 20use DateTime; 21use Exception; 22use Firebase\JWT\ExpiredException; 23use Firebase\JWT\JWT; 24use Firebase\JWT\SignatureInvalidException; 25use Google\Auth\Cache\MemoryCacheItemPool; 26use Google\Auth\HttpHandler\HttpClientCache; 27use Google\Auth\HttpHandler\HttpHandlerFactory; 28use GuzzleHttp\Psr7\Request; 29use GuzzleHttp\Psr7\Utils; 30use InvalidArgumentException; 31use phpseclib\Crypt\RSA; 32use phpseclib\Math\BigInteger; 33use Psr\Cache\CacheItemPoolInterface; 34use RuntimeException; 35use SimpleJWT\InvalidTokenException; 36use SimpleJWT\JWT as SimpleJWT; 37use SimpleJWT\Keys\KeyFactory; 38use SimpleJWT\Keys\KeySet; 39use UnexpectedValueException; 40 41/** 42 * Wrapper around Google Access Tokens which provides convenience functions. 43 * 44 * @experimental 45 */ 46class AccessToken 47{ 48 const FEDERATED_SIGNON_CERT_URL = 'https://www.googleapis.com/oauth2/v3/certs'; 49 const IAP_CERT_URL = 'https://www.gstatic.com/iap/verify/public_key-jwk'; 50 const IAP_ISSUER = 'https://cloud.google.com/iap'; 51 const OAUTH2_ISSUER = 'accounts.google.com'; 52 const OAUTH2_ISSUER_HTTPS = 'https://accounts.google.com'; 53 const OAUTH2_REVOKE_URI = 'https://oauth2.googleapis.com/revoke'; 54 55 /** 56 * @var callable 57 */ 58 private $httpHandler; 59 60 /** 61 * @var CacheItemPoolInterface 62 */ 63 private $cache; 64 65 /** 66 * @param callable $httpHandler [optional] An HTTP Handler to deliver PSR-7 requests. 67 * @param CacheItemPoolInterface $cache [optional] A PSR-6 compatible cache implementation. 68 */ 69 public function __construct( 70 callable $httpHandler = null, 71 CacheItemPoolInterface $cache = null 72 ) { 73 $this->httpHandler = $httpHandler 74 ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 75 $this->cache = $cache ?: new MemoryCacheItemPool(); 76 } 77 78 /** 79 * Verifies an id token and returns the authenticated apiLoginTicket. 80 * Throws an exception if the id token is not valid. 81 * The audience parameter can be used to control which id tokens are 82 * accepted. By default, the id token must have been issued to this OAuth2 client. 83 * 84 * @param string $token The JSON Web Token to be verified. 85 * @param array<mixed> $options [optional] { 86 * Configuration options. 87 * @type string $audience The indended recipient of the token. 88 * @type string $issuer The intended issuer of the token. 89 * @type string $cacheKey The cache key of the cached certs. Defaults to 90 * the sha1 of $certsLocation if provided, otherwise is set to 91 * "federated_signon_certs_v3". 92 * @type string $certsLocation The location (remote or local) from which 93 * to retrieve certificates, if not cached. This value should only be 94 * provided in limited circumstances in which you are sure of the 95 * behavior. 96 * @type bool $throwException Whether the function should throw an 97 * exception if the verification fails. This is useful for 98 * determining the reason verification failed. 99 * } 100 * @return array<mixed>|false the token payload, if successful, or false if not. 101 * @throws InvalidArgumentException If certs could not be retrieved from a local file. 102 * @throws InvalidArgumentException If received certs are in an invalid format. 103 * @throws InvalidArgumentException If the cert alg is not supported. 104 * @throws RuntimeException If certs could not be retrieved from a remote location. 105 * @throws UnexpectedValueException If the token issuer does not match. 106 * @throws UnexpectedValueException If the token audience does not match. 107 */ 108 public function verify($token, array $options = []) 109 { 110 $audience = isset($options['audience']) 111 ? $options['audience'] 112 : null; 113 $issuer = isset($options['issuer']) 114 ? $options['issuer'] 115 : null; 116 $certsLocation = isset($options['certsLocation']) 117 ? $options['certsLocation'] 118 : self::FEDERATED_SIGNON_CERT_URL; 119 $cacheKey = isset($options['cacheKey']) 120 ? $options['cacheKey'] 121 : $this->getCacheKeyFromCertLocation($certsLocation); 122 $throwException = isset($options['throwException']) 123 ? $options['throwException'] 124 : false; // for backwards compatibility 125 126 // Check signature against each available cert. 127 $certs = $this->getCerts($certsLocation, $cacheKey, $options); 128 $alg = $this->determineAlg($certs); 129 if (!in_array($alg, ['RS256', 'ES256'])) { 130 throw new InvalidArgumentException( 131 'unrecognized "alg" in certs, expected ES256 or RS256' 132 ); 133 } 134 try { 135 if ($alg == 'RS256') { 136 return $this->verifyRs256($token, $certs, $audience, $issuer); 137 } 138 return $this->verifyEs256($token, $certs, $audience, $issuer); 139 } catch (ExpiredException $e) { // firebase/php-jwt 5+ 140 } catch (SignatureInvalidException $e) { // firebase/php-jwt 5+ 141 } catch (InvalidTokenException $e) { // simplejwt 142 } catch (DomainException $e) { // @phpstan-ignore-line 143 } catch (InvalidArgumentException $e) { 144 } catch (UnexpectedValueException $e) { 145 } 146 147 if ($throwException) { 148 throw $e; 149 } 150 151 return false; 152 } 153 154 /** 155 * Identifies the expected algorithm to verify by looking at the "alg" key 156 * of the provided certs. 157 * 158 * @param array<mixed> $certs Certificate array according to the JWK spec (see 159 * https://tools.ietf.org/html/rfc7517). 160 * @return string The expected algorithm, such as "ES256" or "RS256". 161 */ 162 private function determineAlg(array $certs) 163 { 164 $alg = null; 165 foreach ($certs as $cert) { 166 if (empty($cert['alg'])) { 167 throw new InvalidArgumentException( 168 'certs expects "alg" to be set' 169 ); 170 } 171 $alg = $alg ?: $cert['alg']; 172 173 if ($alg != $cert['alg']) { 174 throw new InvalidArgumentException( 175 'More than one alg detected in certs' 176 ); 177 } 178 } 179 return $alg; 180 } 181 182 /** 183 * Verifies an ES256-signed JWT. 184 * 185 * @param string $token The JSON Web Token to be verified. 186 * @param array<mixed> $certs Certificate array according to the JWK spec (see 187 * https://tools.ietf.org/html/rfc7517). 188 * @param string|null $audience If set, returns false if the provided 189 * audience does not match the "aud" claim on the JWT. 190 * @param string|null $issuer If set, returns false if the provided 191 * issuer does not match the "iss" claim on the JWT. 192 * @return array<mixed> the token payload, if successful, or false if not. 193 */ 194 private function verifyEs256($token, array $certs, $audience = null, $issuer = null) 195 { 196 $this->checkSimpleJwt(); 197 198 $jwkset = new KeySet(); 199 foreach ($certs as $cert) { 200 $jwkset->add(KeyFactory::create($cert, 'php')); 201 } 202 203 // Validate the signature using the key set and ES256 algorithm. 204 $jwt = $this->callSimpleJwtDecode([$token, $jwkset, 'ES256']); 205 $payload = $jwt->getClaims(); 206 207 if ($audience) { 208 if (!isset($payload['aud']) || $payload['aud'] != $audience) { 209 throw new UnexpectedValueException('Audience does not match'); 210 } 211 } 212 213 // @see https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload 214 $issuer = $issuer ?: self::IAP_ISSUER; 215 if (!isset($payload['iss']) || $payload['iss'] !== $issuer) { 216 throw new UnexpectedValueException('Issuer does not match'); 217 } 218 219 return $payload; 220 } 221 222 /** 223 * Verifies an RS256-signed JWT. 224 * 225 * @param string $token The JSON Web Token to be verified. 226 * @param array<mixed> $certs Certificate array according to the JWK spec (see 227 * https://tools.ietf.org/html/rfc7517). 228 * @param string|null $audience If set, returns false if the provided 229 * audience does not match the "aud" claim on the JWT. 230 * @param string|null $issuer If set, returns false if the provided 231 * issuer does not match the "iss" claim on the JWT. 232 * @return array<mixed> the token payload, if successful, or false if not. 233 */ 234 private function verifyRs256($token, array $certs, $audience = null, $issuer = null) 235 { 236 $this->checkAndInitializePhpsec(); 237 $keys = []; 238 foreach ($certs as $cert) { 239 if (empty($cert['kid'])) { 240 throw new InvalidArgumentException( 241 'certs expects "kid" to be set' 242 ); 243 } 244 if (empty($cert['n']) || empty($cert['e'])) { 245 throw new InvalidArgumentException( 246 'RSA certs expects "n" and "e" to be set' 247 ); 248 } 249 $rsa = new RSA(); 250 $rsa->loadKey([ 251 'n' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ 252 $cert['n'], 253 ]), 256), 254 'e' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [ 255 $cert['e'] 256 ]), 256), 257 ]); 258 259 // create an array of key IDs to certs for the JWT library 260 $keys[$cert['kid']] = $rsa->getPublicKey(); 261 } 262 263 $payload = $this->callJwtStatic('decode', [ 264 $token, 265 $keys, 266 ['RS256'] 267 ]); 268 269 if ($audience) { 270 if (!property_exists($payload, 'aud') || $payload->aud != $audience) { 271 throw new UnexpectedValueException('Audience does not match'); 272 } 273 } 274 275 // support HTTP and HTTPS issuers 276 // @see https://developers.google.com/identity/sign-in/web/backend-auth 277 $issuers = $issuer ? [$issuer] : [self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS]; 278 if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) { 279 throw new UnexpectedValueException('Issuer does not match'); 280 } 281 282 return (array) $payload; 283 } 284 285 /** 286 * Revoke an OAuth2 access token or refresh token. This method will revoke the current access 287 * token, if a token isn't provided. 288 * 289 * @param string|array<mixed> $token The token (access token or a refresh token) that should be revoked. 290 * @param array<mixed> $options [optional] Configuration options. 291 * @return bool Returns True if the revocation was successful, otherwise False. 292 */ 293 public function revoke($token, array $options = []) 294 { 295 if (is_array($token)) { 296 if (isset($token['refresh_token'])) { 297 $token = $token['refresh_token']; 298 } else { 299 $token = $token['access_token']; 300 } 301 } 302 303 $body = Utils::streamFor(http_build_query(['token' => $token])); 304 $request = new Request('POST', self::OAUTH2_REVOKE_URI, [ 305 'Cache-Control' => 'no-store', 306 'Content-Type' => 'application/x-www-form-urlencoded', 307 ], $body); 308 309 $httpHandler = $this->httpHandler; 310 311 $response = $httpHandler($request, $options); 312 313 return $response->getStatusCode() == 200; 314 } 315 316 /** 317 * Gets federated sign-on certificates to use for verifying identity tokens. 318 * Returns certs as array structure, where keys are key ids, and values 319 * are PEM encoded certificates. 320 * 321 * @param string $location The location from which to retrieve certs. 322 * @param string $cacheKey The key under which to cache the retrieved certs. 323 * @param array<mixed> $options [optional] Configuration options. 324 * @return array<mixed> 325 * @throws InvalidArgumentException If received certs are in an invalid format. 326 */ 327 private function getCerts($location, $cacheKey, array $options = []) 328 { 329 $cacheItem = $this->cache->getItem($cacheKey); 330 $certs = $cacheItem ? $cacheItem->get() : null; // @phpstan-ignore-line 331 332 $gotNewCerts = false; 333 if (!$certs) { 334 $certs = $this->retrieveCertsFromLocation($location, $options); 335 336 $gotNewCerts = true; 337 } 338 339 if (!isset($certs['keys'])) { 340 if ($location !== self::IAP_CERT_URL) { 341 throw new InvalidArgumentException( 342 'federated sign-on certs expects "keys" to be set' 343 ); 344 } 345 throw new InvalidArgumentException( 346 'certs expects "keys" to be set' 347 ); 348 } 349 350 // Push caching off until after verifying certs are in a valid format. 351 // Don't want to cache bad data. 352 if ($gotNewCerts) { 353 $cacheItem->expiresAt(new DateTime('+1 hour')); 354 $cacheItem->set($certs); 355 $this->cache->save($cacheItem); 356 } 357 358 return $certs['keys']; 359 } 360 361 /** 362 * Retrieve and cache a certificates file. 363 * 364 * @param string $url location 365 * @param array<mixed> $options [optional] Configuration options. 366 * @return array<mixed> certificates 367 * @throws InvalidArgumentException If certs could not be retrieved from a local file. 368 * @throws RuntimeException If certs could not be retrieved from a remote location. 369 */ 370 private function retrieveCertsFromLocation($url, array $options = []) 371 { 372 // If we're retrieving a local file, just grab it. 373 if (strpos($url, 'http') !== 0) { 374 if (!file_exists($url)) { 375 throw new InvalidArgumentException(sprintf( 376 'Failed to retrieve verification certificates from path: %s.', 377 $url 378 )); 379 } 380 381 return json_decode((string) file_get_contents($url), true); 382 } 383 384 $httpHandler = $this->httpHandler; 385 $response = $httpHandler(new Request('GET', $url), $options); 386 387 if ($response->getStatusCode() == 200) { 388 return json_decode((string) $response->getBody(), true); 389 } 390 391 throw new RuntimeException(sprintf( 392 'Failed to retrieve verification certificates: "%s".', 393 $response->getBody()->getContents() 394 ), $response->getStatusCode()); 395 } 396 397 /** 398 * @return void 399 */ 400 private function checkAndInitializePhpsec() 401 { 402 // @codeCoverageIgnoreStart 403 if (!class_exists('phpseclib\Crypt\RSA')) { 404 throw new RuntimeException('Please require phpseclib/phpseclib v2 to use this utility.'); 405 } 406 // @codeCoverageIgnoreEnd 407 408 $this->setPhpsecConstants(); 409 } 410 411 /** 412 * @return void 413 */ 414 private function checkSimpleJwt() 415 { 416 // @codeCoverageIgnoreStart 417 if (!class_exists(SimpleJwt::class)) { 418 throw new RuntimeException('Please require kelvinmo/simplejwt ^0.2 to use this utility.'); 419 } 420 // @codeCoverageIgnoreEnd 421 } 422 423 /** 424 * phpseclib calls "phpinfo" by default, which requires special 425 * whitelisting in the AppEngine VM environment. This function 426 * sets constants to bypass the need for phpseclib to check phpinfo 427 * 428 * @see phpseclib/Math/BigInteger 429 * @see https://github.com/GoogleCloudPlatform/getting-started-php/issues/85 430 * @codeCoverageIgnore 431 * 432 * @return void 433 */ 434 private function setPhpsecConstants() 435 { 436 if (filter_var(getenv('GAE_VM'), FILTER_VALIDATE_BOOLEAN)) { 437 if (!defined('MATH_BIGINTEGER_OPENSSL_ENABLED')) { 438 define('MATH_BIGINTEGER_OPENSSL_ENABLED', true); 439 } 440 if (!defined('CRYPT_RSA_MODE')) { 441 define('CRYPT_RSA_MODE', RSA::MODE_OPENSSL); 442 } 443 } 444 } 445 446 /** 447 * Provide a hook to mock calls to the JWT static methods. 448 * 449 * @param string $method 450 * @param array<mixed> $args 451 * @return mixed 452 */ 453 protected function callJwtStatic($method, array $args = []) 454 { 455 return call_user_func_array([JWT::class, $method], $args); // @phpstan-ignore-line 456 } 457 458 /** 459 * Provide a hook to mock calls to the JWT static methods. 460 * 461 * @param array<mixed> $args 462 * @return mixed 463 */ 464 protected function callSimpleJwtDecode(array $args = []) 465 { 466 return call_user_func_array([SimpleJwt::class, 'decode'], $args); 467 } 468 469 /** 470 * Generate a cache key based on the cert location using sha1 with the 471 * exception of using "federated_signon_certs_v3" to preserve BC. 472 * 473 * @param string $certsLocation 474 * @return string 475 */ 476 private function getCacheKeyFromCertLocation($certsLocation) 477 { 478 $key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL 479 ? 'federated_signon_certs_v3' 480 : sha1($certsLocation); 481 482 return 'google_auth_certs_cache|' . $key; 483 } 484} 485