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\OAuth2;
23use Google\Auth\ProjectIdProviderInterface;
24use Google\Auth\ServiceAccountSignerTrait;
25use Google\Auth\SignBlobInterface;
26use InvalidArgumentException;
27
28/**
29 * ServiceAccountCredentials supports authorization using a Google service
30 * account.
31 *
32 * (cf https://developers.google.com/accounts/docs/OAuth2ServiceAccount)
33 *
34 * It's initialized using the json key file that's downloadable from developer
35 * console, which should contain a private_key and client_email fields that it
36 * uses.
37 *
38 * Use it with AuthTokenMiddleware to authorize http requests:
39 *
40 *   use Google\Auth\Credentials\ServiceAccountCredentials;
41 *   use Google\Auth\Middleware\AuthTokenMiddleware;
42 *   use GuzzleHttp\Client;
43 *   use GuzzleHttp\HandlerStack;
44 *
45 *   $sa = new ServiceAccountCredentials(
46 *       'https://www.googleapis.com/auth/taskqueue',
47 *       '/path/to/your/json/key_file.json'
48 *   );
49 *   $middleware = new AuthTokenMiddleware($sa);
50 *   $stack = HandlerStack::create();
51 *   $stack->push($middleware);
52 *
53 *   $client = new Client([
54 *       'handler' => $stack,
55 *       'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/',
56 *       'auth' => 'google_auth' // authorize all requests
57 *   ]);
58 *
59 *   $res = $client->get('myproject/taskqueues/myqueue');
60 */
61class ServiceAccountCredentials extends CredentialsLoader implements
62    GetQuotaProjectInterface,
63    SignBlobInterface,
64    ProjectIdProviderInterface
65{
66    use ServiceAccountSignerTrait;
67
68    /**
69     * The OAuth2 instance used to conduct authorization.
70     *
71     * @var OAuth2
72     */
73    protected $auth;
74
75    /**
76     * The quota project associated with the JSON credentials
77     *
78     * @var string
79     */
80    protected $quotaProject;
81
82    /**
83     * @var string|null
84     */
85    protected $projectId;
86
87    /**
88     * @var array<mixed>|null
89     */
90    private $lastReceivedJwtAccessToken;
91
92    /**
93     * @var bool
94     */
95    private $useJwtAccessWithScope = false;
96
97    /**
98     * @var ServiceAccountJwtAccessCredentials|null
99     */
100    private $jwtAccessCredentials;
101
102    /**
103     * Create a new ServiceAccountCredentials.
104     *
105     * @param string|string[]|null $scope the scope of the access request, expressed
106     *   either as an Array or as a space-delimited String.
107     * @param string|array<mixed> $jsonKey JSON credential file path or JSON credentials
108     *   as an associative array
109     * @param string $sub an email address account to impersonate, in situations when
110     *   the service account has been delegated domain wide access.
111     * @param string $targetAudience The audience for the ID token.
112     */
113    public function __construct(
114        $scope,
115        $jsonKey,
116        $sub = null,
117        $targetAudience = null
118    ) {
119        if (is_string($jsonKey)) {
120            if (!file_exists($jsonKey)) {
121                throw new \InvalidArgumentException('file does not exist');
122            }
123            $jsonKeyStream = file_get_contents($jsonKey);
124            if (!$jsonKey = json_decode((string) $jsonKeyStream, true)) {
125                throw new \LogicException('invalid json for auth config');
126            }
127        }
128        if (!array_key_exists('client_email', $jsonKey)) {
129            throw new \InvalidArgumentException(
130                'json key is missing the client_email field'
131            );
132        }
133        if (!array_key_exists('private_key', $jsonKey)) {
134            throw new \InvalidArgumentException(
135                'json key is missing the private_key field'
136            );
137        }
138        if (array_key_exists('quota_project_id', $jsonKey)) {
139            $this->quotaProject = (string) $jsonKey['quota_project_id'];
140        }
141        if ($scope && $targetAudience) {
142            throw new InvalidArgumentException(
143                'Scope and targetAudience cannot both be supplied'
144            );
145        }
146        $additionalClaims = [];
147        if ($targetAudience) {
148            $additionalClaims = ['target_audience' => $targetAudience];
149        }
150        $this->auth = new OAuth2([
151            'audience' => self::TOKEN_CREDENTIAL_URI,
152            'issuer' => $jsonKey['client_email'],
153            'scope' => $scope,
154            'signingAlgorithm' => 'RS256',
155            'signingKey' => $jsonKey['private_key'],
156            'sub' => $sub,
157            'tokenCredentialUri' => self::TOKEN_CREDENTIAL_URI,
158            'additionalClaims' => $additionalClaims,
159        ]);
160
161        $this->projectId = isset($jsonKey['project_id'])
162            ? $jsonKey['project_id']
163            : null;
164    }
165
166    /**
167     * When called, the ServiceAccountCredentials will use an instance of
168     * ServiceAccountJwtAccessCredentials to fetch (self-sign) an access token
169     * even when only scopes are supplied. Otherwise,
170     * ServiceAccountJwtAccessCredentials is only called when no scopes and an
171     * authUrl (audience) is suppled.
172     *
173     * @return void
174     */
175    public function useJwtAccessWithScope()
176    {
177        $this->useJwtAccessWithScope = true;
178    }
179
180    /**
181     * @param callable $httpHandler
182     *
183     * @return array<mixed> {
184     *     A set of auth related metadata, containing the following
185     *
186     *     @type string $access_token
187     *     @type int $expires_in
188     *     @type string $token_type
189     * }
190     */
191    public function fetchAuthToken(callable $httpHandler = null)
192    {
193        if ($this->useSelfSignedJwt()) {
194            $jwtCreds = $this->createJwtAccessCredentials();
195
196            $accessToken = $jwtCreds->fetchAuthToken($httpHandler);
197
198            if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) {
199                // Keep self-signed JWTs in memory as the last received token
200                $this->lastReceivedJwtAccessToken = $lastReceivedToken;
201            }
202
203            return $accessToken;
204        }
205        return $this->auth->fetchAuthToken($httpHandler);
206    }
207
208    /**
209     * @return string
210     */
211    public function getCacheKey()
212    {
213        $key = $this->auth->getIssuer() . ':' . $this->auth->getCacheKey();
214        if ($sub = $this->auth->getSub()) {
215            $key .= ':' . $sub;
216        }
217
218        return $key;
219    }
220
221    /**
222     * @return array<mixed>
223     */
224    public function getLastReceivedToken()
225    {
226        // If self-signed JWTs are being used, fetch the last received token
227        // from memory. Else, fetch it from OAuth2
228        return $this->useSelfSignedJwt()
229            ? $this->lastReceivedJwtAccessToken
230            : $this->auth->getLastReceivedToken();
231    }
232
233    /**
234     * Get the project ID from the service account keyfile.
235     *
236     * Returns null if the project ID does not exist in the keyfile.
237     *
238     * @param callable $httpHandler Not used by this credentials type.
239     * @return string|null
240     */
241    public function getProjectId(callable $httpHandler = null)
242    {
243        return $this->projectId;
244    }
245
246    /**
247     * Updates metadata with the authorization token.
248     *
249     * @param array<mixed> $metadata metadata hashmap
250     * @param string $authUri optional auth uri
251     * @param callable $httpHandler callback which delivers psr7 request
252     * @return array<mixed> updated metadata hashmap
253     */
254    public function updateMetadata(
255        $metadata,
256        $authUri = null,
257        callable $httpHandler = null
258    ) {
259        // scope exists. use oauth implementation
260        if (!$this->useSelfSignedJwt()) {
261            return parent::updateMetadata($metadata, $authUri, $httpHandler);
262        }
263
264        $jwtCreds = $this->createJwtAccessCredentials();
265        if ($this->auth->getScope()) {
266            // Prefer user-provided "scope" to "audience"
267            $updatedMetadata = $jwtCreds->updateMetadata($metadata, null, $httpHandler);
268        } else {
269            $updatedMetadata = $jwtCreds->updateMetadata($metadata, $authUri, $httpHandler);
270        }
271
272        if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) {
273            // Keep self-signed JWTs in memory as the last received token
274            $this->lastReceivedJwtAccessToken = $lastReceivedToken;
275        }
276
277        return $updatedMetadata;
278    }
279
280    /**
281     * @return ServiceAccountJwtAccessCredentials
282     */
283    private function createJwtAccessCredentials()
284    {
285        if (!$this->jwtAccessCredentials) {
286            // Create credentials for self-signing a JWT (JwtAccess)
287            $credJson = [
288                'private_key' => $this->auth->getSigningKey(),
289                'client_email' => $this->auth->getIssuer(),
290            ];
291            $this->jwtAccessCredentials = new ServiceAccountJwtAccessCredentials(
292                $credJson,
293                $this->auth->getScope()
294            );
295        }
296
297        return $this->jwtAccessCredentials;
298    }
299
300    /**
301     * @param string $sub an email address account to impersonate, in situations when
302     *   the service account has been delegated domain wide access.
303     * @return void
304     */
305    public function setSub($sub)
306    {
307        $this->auth->setSub($sub);
308    }
309
310    /**
311     * Get the client name from the keyfile.
312     *
313     * In this case, it returns the keyfile's client_email key.
314     *
315     * @param callable $httpHandler Not used by this credentials type.
316     * @return string
317     */
318    public function getClientName(callable $httpHandler = null)
319    {
320        return $this->auth->getIssuer();
321    }
322
323    /**
324     * Get the quota project used for this API request
325     *
326     * @return string|null
327     */
328    public function getQuotaProject()
329    {
330        return $this->quotaProject;
331    }
332
333    /**
334     * @return bool
335     */
336    private function useSelfSignedJwt()
337    {
338        // If claims are set, this call is for "id_tokens"
339        if ($this->auth->getAdditionalClaims()) {
340            return false;
341        }
342
343        // When true, ServiceAccountCredentials will always use JwtAccess for access tokens
344        if ($this->useJwtAccessWithScope) {
345            return true;
346        }
347        return is_null($this->auth->getScope());
348    }
349}
350