| 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 |
|
| 18 |
namespace Google\Auth; |
| 19 |
|
| 20 |
use DateTime; |
| 21 |
use Exception; |
| 22 |
use Firebase\JWT\ExpiredException; |
| 23 |
use Firebase\JWT\JWT; |
| 24 |
use Firebase\JWT\SignatureInvalidException; |
| 25 |
use Google\Auth\Cache\MemoryCacheItemPool; |
| 26 |
use Google\Auth\HttpHandler\HttpClientCache; |
| 27 |
use Google\Auth\HttpHandler\HttpHandlerFactory; |
| 28 |
use GuzzleHttp\Psr7\Request; |
| 29 |
use GuzzleHttp\Psr7\Utils; |
| 30 |
use InvalidArgumentException; |
| 31 |
use phpseclib\Crypt\RSA; |
| 32 |
use phpseclib\Math\BigInteger; |
| 33 |
use Psr\Cache\CacheItemPoolInterface; |
| 34 |
use RuntimeException; |
| 35 |
use SimpleJWT\InvalidTokenException; |
| 36 |
use SimpleJWT\JWT as SimpleJWT; |
| 37 |
use SimpleJWT\Keys\KeyFactory; |
| 38 |
use SimpleJWT\Keys\KeySet; |
| 39 |
use UnexpectedValueException; |
| 40 |
|
| 41 |
/** |
| 42 |
* Wrapper around Google Access Tokens which provides convenience functions. |
| 43 |
* |
| 44 |
* @experimental |
| 45 |
*/ |
| 46 |
class 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 |
|