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