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