1<?php 2/* 3 * Copyright 2015 Google Inc. 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\Credentials; 19 20use Google\Auth\CredentialsLoader; 21use Google\Auth\GetQuotaProjectInterface; 22use Google\Auth\HttpHandler\HttpClientCache; 23use Google\Auth\HttpHandler\HttpHandlerFactory; 24use Google\Auth\Iam; 25use Google\Auth\ProjectIdProviderInterface; 26use Google\Auth\SignBlobInterface; 27use GuzzleHttp\Exception\ClientException; 28use GuzzleHttp\Exception\ConnectException; 29use GuzzleHttp\Exception\RequestException; 30use GuzzleHttp\Exception\ServerException; 31use GuzzleHttp\Psr7\Request; 32use InvalidArgumentException; 33 34/** 35 * GCECredentials supports authorization on Google Compute Engine. 36 * 37 * It can be used to authorize requests using the AuthTokenMiddleware, but will 38 * only succeed if being run on GCE: 39 * 40 * use Google\Auth\Credentials\GCECredentials; 41 * use Google\Auth\Middleware\AuthTokenMiddleware; 42 * use GuzzleHttp\Client; 43 * use GuzzleHttp\HandlerStack; 44 * 45 * $gce = new GCECredentials(); 46 * $middleware = new AuthTokenMiddleware($gce); 47 * $stack = HandlerStack::create(); 48 * $stack->push($middleware); 49 * 50 * $client = new Client([ 51 * 'handler' => $stack, 52 * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', 53 * 'auth' => 'google_auth' 54 * ]); 55 * 56 * $res = $client->get('myproject/taskqueues/myqueue'); 57 */ 58class GCECredentials extends CredentialsLoader implements 59 SignBlobInterface, 60 ProjectIdProviderInterface, 61 GetQuotaProjectInterface 62{ 63 // phpcs:disable 64 const cacheKey = 'GOOGLE_AUTH_PHP_GCE'; 65 // phpcs:enable 66 67 /** 68 * The metadata IP address on appengine instances. 69 * 70 * The IP is used instead of the domain 'metadata' to avoid slow responses 71 * when not on Compute Engine. 72 */ 73 const METADATA_IP = '169.254.169.254'; 74 75 /** 76 * The metadata path of the default token. 77 */ 78 const TOKEN_URI_PATH = 'v1/instance/service-accounts/default/token'; 79 80 /** 81 * The metadata path of the default id token. 82 */ 83 const ID_TOKEN_URI_PATH = 'v1/instance/service-accounts/default/identity'; 84 85 /** 86 * The metadata path of the client ID. 87 */ 88 const CLIENT_ID_URI_PATH = 'v1/instance/service-accounts/default/email'; 89 90 /** 91 * The metadata path of the project ID. 92 */ 93 const PROJECT_ID_URI_PATH = 'v1/project/project-id'; 94 95 /** 96 * The header whose presence indicates GCE presence. 97 */ 98 const FLAVOR_HEADER = 'Metadata-Flavor'; 99 100 /** 101 * Note: the explicit `timeout` and `tries` below is a workaround. The underlying 102 * issue is that resolving an unknown host on some networks will take 103 * 20-30 seconds; making this timeout short fixes the issue, but 104 * could lead to false negatives in the event that we are on GCE, but 105 * the metadata resolution was particularly slow. The latter case is 106 * "unlikely" since the expected 4-nines time is about 0.5 seconds. 107 * This allows us to limit the total ping maximum timeout to 1.5 seconds 108 * for developer desktop scenarios. 109 */ 110 const MAX_COMPUTE_PING_TRIES = 3; 111 const COMPUTE_PING_CONNECTION_TIMEOUT_S = 0.5; 112 113 /** 114 * Flag used to ensure that the onGCE test is only done once;. 115 * 116 * @var bool 117 */ 118 private $hasCheckedOnGce = false; 119 120 /** 121 * Flag that stores the value of the onGCE check. 122 * 123 * @var bool 124 */ 125 private $isOnGce = false; 126 127 /** 128 * Result of fetchAuthToken. 129 * 130 * @var array<mixed> 131 */ 132 protected $lastReceivedToken; 133 134 /** 135 * @var string|null 136 */ 137 private $clientName; 138 139 /** 140 * @var string|null 141 */ 142 private $projectId; 143 144 /** 145 * @var Iam|null 146 */ 147 private $iam; 148 149 /** 150 * @var string 151 */ 152 private $tokenUri; 153 154 /** 155 * @var string 156 */ 157 private $targetAudience; 158 159 /** 160 * @var string|null 161 */ 162 private $quotaProject; 163 164 /** 165 * @var string|null 166 */ 167 private $serviceAccountIdentity; 168 169 /** 170 * @param Iam $iam [optional] An IAM instance. 171 * @param string|string[] $scope [optional] the scope of the access request, 172 * expressed either as an array or as a space-delimited string. 173 * @param string $targetAudience [optional] The audience for the ID token. 174 * @param string $quotaProject [optional] Specifies a project to bill for access 175 * charges associated with the request. 176 * @param string $serviceAccountIdentity [optional] Specify a service 177 * account identity name to use instead of "default". 178 */ 179 public function __construct( 180 Iam $iam = null, 181 $scope = null, 182 $targetAudience = null, 183 $quotaProject = null, 184 $serviceAccountIdentity = null 185 ) { 186 $this->iam = $iam; 187 188 if ($scope && $targetAudience) { 189 throw new InvalidArgumentException( 190 'Scope and targetAudience cannot both be supplied' 191 ); 192 } 193 194 $tokenUri = self::getTokenUri($serviceAccountIdentity); 195 if ($scope) { 196 if (is_string($scope)) { 197 $scope = explode(' ', $scope); 198 } 199 200 $scope = implode(',', $scope); 201 202 $tokenUri = $tokenUri . '?scopes=' . $scope; 203 } elseif ($targetAudience) { 204 $tokenUri = self::getIdTokenUri($serviceAccountIdentity); 205 $tokenUri = $tokenUri . '?audience=' . $targetAudience; 206 $this->targetAudience = $targetAudience; 207 } 208 209 $this->tokenUri = $tokenUri; 210 $this->quotaProject = $quotaProject; 211 $this->serviceAccountIdentity = $serviceAccountIdentity; 212 } 213 214 /** 215 * The full uri for accessing the default token. 216 * 217 * @param string $serviceAccountIdentity [optional] Specify a service 218 * account identity name to use instead of "default". 219 * @return string 220 */ 221 public static function getTokenUri($serviceAccountIdentity = null) 222 { 223 $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; 224 $base .= self::TOKEN_URI_PATH; 225 226 if ($serviceAccountIdentity) { 227 return str_replace( 228 '/default/', 229 '/' . $serviceAccountIdentity . '/', 230 $base 231 ); 232 } 233 return $base; 234 } 235 236 /** 237 * The full uri for accessing the default service account. 238 * 239 * @param string $serviceAccountIdentity [optional] Specify a service 240 * account identity name to use instead of "default". 241 * @return string 242 */ 243 public static function getClientNameUri($serviceAccountIdentity = null) 244 { 245 $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; 246 $base .= self::CLIENT_ID_URI_PATH; 247 248 if ($serviceAccountIdentity) { 249 return str_replace( 250 '/default/', 251 '/' . $serviceAccountIdentity . '/', 252 $base 253 ); 254 } 255 256 return $base; 257 } 258 259 /** 260 * The full uri for accesesing the default identity token. 261 * 262 * @param string $serviceAccountIdentity [optional] Specify a service 263 * account identity name to use instead of "default". 264 * @return string 265 */ 266 private static function getIdTokenUri($serviceAccountIdentity = null) 267 { 268 $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; 269 $base .= self::ID_TOKEN_URI_PATH; 270 271 if ($serviceAccountIdentity) { 272 return str_replace( 273 '/default/', 274 '/' . $serviceAccountIdentity . '/', 275 $base 276 ); 277 } 278 279 return $base; 280 } 281 282 /** 283 * The full uri for accessing the default project ID. 284 * 285 * @return string 286 */ 287 private static function getProjectIdUri() 288 { 289 $base = 'http://' . self::METADATA_IP . '/computeMetadata/'; 290 291 return $base . self::PROJECT_ID_URI_PATH; 292 } 293 294 /** 295 * Determines if this an App Engine Flexible instance, by accessing the 296 * GAE_INSTANCE environment variable. 297 * 298 * @return bool true if this an App Engine Flexible Instance, false otherwise 299 */ 300 public static function onAppEngineFlexible() 301 { 302 return substr((string) getenv('GAE_INSTANCE'), 0, 4) === 'aef-'; 303 } 304 305 /** 306 * Determines if this a GCE instance, by accessing the expected metadata 307 * host. 308 * If $httpHandler is not specified a the default HttpHandler is used. 309 * 310 * @param callable $httpHandler callback which delivers psr7 request 311 * @return bool True if this a GCEInstance, false otherwise 312 */ 313 public static function onGce(callable $httpHandler = null) 314 { 315 $httpHandler = $httpHandler 316 ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 317 318 $checkUri = 'http://' . self::METADATA_IP; 319 for ($i = 1; $i <= self::MAX_COMPUTE_PING_TRIES; $i++) { 320 try { 321 // Comment from: oauth2client/client.py 322 // 323 // Note: the explicit `timeout` below is a workaround. The underlying 324 // issue is that resolving an unknown host on some networks will take 325 // 20-30 seconds; making this timeout short fixes the issue, but 326 // could lead to false negatives in the event that we are on GCE, but 327 // the metadata resolution was particularly slow. The latter case is 328 // "unlikely". 329 $resp = $httpHandler( 330 new Request( 331 'GET', 332 $checkUri, 333 [self::FLAVOR_HEADER => 'Google'] 334 ), 335 ['timeout' => self::COMPUTE_PING_CONNECTION_TIMEOUT_S] 336 ); 337 338 return $resp->getHeaderLine(self::FLAVOR_HEADER) == 'Google'; 339 } catch (ClientException $e) { 340 } catch (ServerException $e) { 341 } catch (RequestException $e) { 342 } catch (ConnectException $e) { 343 } 344 } 345 return false; 346 } 347 348 /** 349 * Implements FetchAuthTokenInterface#fetchAuthToken. 350 * 351 * Fetches the auth tokens from the GCE metadata host if it is available. 352 * If $httpHandler is not specified a the default HttpHandler is used. 353 * 354 * @param callable $httpHandler callback which delivers psr7 request 355 * 356 * @return array<mixed> { 357 * A set of auth related metadata, based on the token type. 358 * 359 * @type string $access_token for access tokens 360 * @type int $expires_in for access tokens 361 * @type string $token_type for access tokens 362 * @type string $id_token for ID tokens 363 * } 364 * @throws \Exception 365 */ 366 public function fetchAuthToken(callable $httpHandler = null) 367 { 368 $httpHandler = $httpHandler 369 ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 370 371 if (!$this->hasCheckedOnGce) { 372 $this->isOnGce = self::onGce($httpHandler); 373 $this->hasCheckedOnGce = true; 374 } 375 if (!$this->isOnGce) { 376 return array(); // return an empty array with no access token 377 } 378 379 $response = $this->getFromMetadata($httpHandler, $this->tokenUri); 380 381 if ($this->targetAudience) { 382 return ['id_token' => $response]; 383 } 384 385 if (null === $json = json_decode($response, true)) { 386 throw new \Exception('Invalid JSON response'); 387 } 388 389 $json['expires_at'] = time() + $json['expires_in']; 390 391 // store this so we can retrieve it later 392 $this->lastReceivedToken = $json; 393 394 return $json; 395 } 396 397 /** 398 * @return string 399 */ 400 public function getCacheKey() 401 { 402 return self::cacheKey; 403 } 404 405 /** 406 * @return array{access_token:string,expires_at:int}|null 407 */ 408 public function getLastReceivedToken() 409 { 410 if ($this->lastReceivedToken) { 411 return [ 412 'access_token' => $this->lastReceivedToken['access_token'], 413 'expires_at' => $this->lastReceivedToken['expires_at'], 414 ]; 415 } 416 417 return null; 418 } 419 420 /** 421 * Get the client name from GCE metadata. 422 * 423 * Subsequent calls will return a cached value. 424 * 425 * @param callable $httpHandler callback which delivers psr7 request 426 * @return string 427 */ 428 public function getClientName(callable $httpHandler = null) 429 { 430 if ($this->clientName) { 431 return $this->clientName; 432 } 433 434 $httpHandler = $httpHandler 435 ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 436 437 if (!$this->hasCheckedOnGce) { 438 $this->isOnGce = self::onGce($httpHandler); 439 $this->hasCheckedOnGce = true; 440 } 441 442 if (!$this->isOnGce) { 443 return ''; 444 } 445 446 $this->clientName = $this->getFromMetadata( 447 $httpHandler, 448 self::getClientNameUri($this->serviceAccountIdentity) 449 ); 450 451 return $this->clientName; 452 } 453 454 /** 455 * Sign a string using the default service account private key. 456 * 457 * This implementation uses IAM's signBlob API. 458 * 459 * @see https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/signBlob SignBlob 460 * 461 * @param string $stringToSign The string to sign. 462 * @param bool $forceOpenSsl [optional] Does not apply to this credentials 463 * type. 464 * @param string $accessToken The access token to use to sign the blob. If 465 * provided, saves a call to the metadata server for a new access 466 * token. **Defaults to** `null`. 467 * @return string 468 */ 469 public function signBlob($stringToSign, $forceOpenSsl = false, $accessToken = null) 470 { 471 $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 472 473 // Providing a signer is useful for testing, but it's undocumented 474 // because it's not something a user would generally need to do. 475 $signer = $this->iam ?: new Iam($httpHandler); 476 477 $email = $this->getClientName($httpHandler); 478 479 if (is_null($accessToken)) { 480 $previousToken = $this->getLastReceivedToken(); 481 $accessToken = $previousToken 482 ? $previousToken['access_token'] 483 : $this->fetchAuthToken($httpHandler)['access_token']; 484 } 485 486 return $signer->signBlob($email, $accessToken, $stringToSign); 487 } 488 489 /** 490 * Fetch the default Project ID from compute engine. 491 * 492 * Returns null if called outside GCE. 493 * 494 * @param callable $httpHandler Callback which delivers psr7 request 495 * @return string|null 496 */ 497 public function getProjectId(callable $httpHandler = null) 498 { 499 if ($this->projectId) { 500 return $this->projectId; 501 } 502 503 $httpHandler = $httpHandler 504 ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 505 506 if (!$this->hasCheckedOnGce) { 507 $this->isOnGce = self::onGce($httpHandler); 508 $this->hasCheckedOnGce = true; 509 } 510 511 if (!$this->isOnGce) { 512 return null; 513 } 514 515 $this->projectId = $this->getFromMetadata($httpHandler, self::getProjectIdUri()); 516 return $this->projectId; 517 } 518 519 /** 520 * Fetch the value of a GCE metadata server URI. 521 * 522 * @param callable $httpHandler An HTTP Handler to deliver PSR7 requests. 523 * @param string $uri The metadata URI. 524 * @return string 525 */ 526 private function getFromMetadata(callable $httpHandler, $uri) 527 { 528 $resp = $httpHandler( 529 new Request( 530 'GET', 531 $uri, 532 [self::FLAVOR_HEADER => 'Google'] 533 ) 534 ); 535 536 return (string) $resp->getBody(); 537 } 538 539 /** 540 * Get the quota project used for this API request 541 * 542 * @return string|null 543 */ 544 public function getQuotaProject() 545 { 546 return $this->quotaProject; 547 } 548} 549