PluginProbe
Gmail SMTP / trunk
Gmail SMTP vtrunk
1.2.3.21 1.2.3.20 trunk 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.9 1.2.0 1.2.3.14 1.2.3.15 1.2.3.16 1.2.3.18 1.2.3.5
gmail-smtp / google-api-php-client / vendor / google / auth / src / Credentials / ExternalAccountCredentials.php

ExternalAccountCredentials.php in Gmail SMTP trunk, at google-api-php-client/vendor/google/auth/src/Credentials/ExternalAccountCredentials.php

476 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /*
3 * Copyright 2023 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
18 namespace Google\Auth\Credentials;
19
20 use Google\Auth\CredentialSource\AwsNativeSource;
21 use Google\Auth\CredentialSource\ExecutableSource;
22 use Google\Auth\CredentialSource\FileSource;
23 use Google\Auth\CredentialSource\UrlSource;
24 use Google\Auth\ExecutableHandler\ExecutableHandler;
25 use Google\Auth\ExternalAccountCredentialSourceInterface;
26 use Google\Auth\FetchAuthTokenInterface;
27 use Google\Auth\GetQuotaProjectInterface;
28 use Google\Auth\GetUniverseDomainInterface;
29 use Google\Auth\HttpHandler\HttpClientCache;
30 use Google\Auth\HttpHandler\HttpHandlerFactory;
31 use Google\Auth\OAuth2;
32 use Google\Auth\ProjectIdProviderInterface;
33 use Google\Auth\UpdateMetadataInterface;
34 use Google\Auth\UpdateMetadataTrait;
35 use GuzzleHttp\Psr7\Request;
36 use InvalidArgumentException;
37 use LogicException;
38
39 /**
40 * **IMPORTANT**:
41 * This class does not validate the credential configuration. A security
42 * risk occurs when a credential configuration configured with malicious urls
43 * is used.
44 * When the credential configuration is accepted from an
45 * untrusted source, you should validate it before creating this class.
46 * @see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
47 */
48 class ExternalAccountCredentials implements
49 FetchAuthTokenInterface,
50 UpdateMetadataInterface,
51 GetQuotaProjectInterface,
52 GetUniverseDomainInterface,
53 ProjectIdProviderInterface
54 {
55 use UpdateMetadataTrait {
56 updateMetadata as traitUpdateMetadata;
57 }
58 use RegionalAccessBoundaryTrait {
59 buildRegionalAccessBoundaryLookupUrl as traitBuildRegionalAccessBoundaryLookupUrl;
60 }
61
62 private const EXTERNAL_ACCOUNT_TYPE = 'external_account';
63 private const CLOUD_RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.UNIVERSE_DOMAIN/v1/projects/%s';
64
65 private OAuth2 $auth;
66 private ?string $quotaProject;
67 private ?string $serviceAccountImpersonationUrl;
68 private ?string $workforcePoolUserProject;
69 private ?string $projectId;
70 /** @var array<mixed> */
71 private ?array $lastImpersonatedAccessToken;
72 private string $universeDomain;
73
74 /**
75 * @param string|string[] $scope The scope of the access request, expressed either as an array
76 * or as a space-delimited string.
77 * @param array<mixed> $jsonKey JSON credentials as an associative array.
78 * @param bool $enableRegionalAccessBoundary Lookup and include the regional access boundary header.
79 */
80 public function __construct(
81 $scope,
82 array $jsonKey,
83 bool $enableRegionalAccessBoundary = false
84 ) {
85 if (!array_key_exists('type', $jsonKey)) {
86 throw new InvalidArgumentException('json key is missing the type field');
87 }
88 if ($jsonKey['type'] !== self::EXTERNAL_ACCOUNT_TYPE) {
89 throw new InvalidArgumentException(sprintf(
90 'expected "%s" type but received "%s"',
91 self::EXTERNAL_ACCOUNT_TYPE,
92 $jsonKey['type']
93 ));
94 }
95
96 if (!array_key_exists('token_url', $jsonKey)) {
97 throw new InvalidArgumentException(
98 'json key is missing the token_url field'
99 );
100 }
101
102 if (!array_key_exists('audience', $jsonKey)) {
103 throw new InvalidArgumentException(
104 'json key is missing the audience field'
105 );
106 }
107
108 if (!array_key_exists('subject_token_type', $jsonKey)) {
109 throw new InvalidArgumentException(
110 'json key is missing the subject_token_type field'
111 );
112 }
113
114 if (!array_key_exists('credential_source', $jsonKey)) {
115 throw new InvalidArgumentException(
116 'json key is missing the credential_source field'
117 );
118 }
119
120 $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null;
121
122 $this->quotaProject = $jsonKey['quota_project_id'] ?? null;
123 $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null;
124 $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN;
125 $this->enableRegionalAccessBoundary = $enableRegionalAccessBoundary;
126
127 $this->auth = new OAuth2([
128 'tokenCredentialUri' => $jsonKey['token_url'],
129 'audience' => $jsonKey['audience'],
130 'scope' => $scope,
131 'subjectTokenType' => $jsonKey['subject_token_type'],
132 'subjectTokenFetcher' => self::buildCredentialSource($jsonKey),
133 'additionalOptions' => $this->workforcePoolUserProject
134 ? ['userProject' => $this->workforcePoolUserProject]
135 : [],
136 ]);
137
138 if (!$this->isWorkforcePool() && $this->workforcePoolUserProject) {
139 throw new InvalidArgumentException(
140 'workforce_pool_user_project should not be set for non-workforce pool credentials.'
141 );
142 }
143 }
144
145 /**
146 * @param array<mixed> $jsonKey
147 */
148 private static function buildCredentialSource(array $jsonKey): ExternalAccountCredentialSourceInterface
149 {
150 $credentialSource = $jsonKey['credential_source'];
151 if (isset($credentialSource['file'])) {
152 return new FileSource(
153 $credentialSource['file'],
154 $credentialSource['format']['type'] ?? null,
155 $credentialSource['format']['subject_token_field_name'] ?? null
156 );
157 }
158
159 if (
160 isset($credentialSource['environment_id'])
161 && 1 === preg_match('/^aws(\d+)$/', $credentialSource['environment_id'], $matches)
162 ) {
163 if ($matches[1] !== '1') {
164 throw new InvalidArgumentException(
165 "aws version \"$matches[1]\" is not supported in the current build."
166 );
167 }
168 if (!array_key_exists('regional_cred_verification_url', $credentialSource)) {
169 throw new InvalidArgumentException(
170 'The regional_cred_verification_url field is required for aws1 credential source.'
171 );
172 }
173
174 return new AwsNativeSource(
175 $jsonKey['audience'],
176 $credentialSource['regional_cred_verification_url'], // $regionalCredVerificationUrl
177 $credentialSource['region_url'] ?? null, // $regionUrl
178 $credentialSource['url'] ?? null, // $securityCredentialsUrl
179 $credentialSource['imdsv2_session_token_url'] ?? null, // $imdsV2TokenUrl
180 );
181 }
182
183 if (isset($credentialSource['url'])) {
184 return new UrlSource(
185 $credentialSource['url'],
186 $credentialSource['format']['type'] ?? null,
187 $credentialSource['format']['subject_token_field_name'] ?? null,
188 $credentialSource['headers'] ?? null,
189 );
190 }
191
192 if (isset($credentialSource['executable'])) {
193 if (!array_key_exists('command', $credentialSource['executable'])) {
194 throw new InvalidArgumentException(
195 'executable source requires a command to be set in the JSON file.'
196 );
197 }
198
199 // Build command environment variables
200 $env = [
201 'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'],
202 'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'],
203 // Always set to 0 because interactive mode is not supported.
204 'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0',
205 ];
206
207 if ($outputFile = $credentialSource['executable']['output_file'] ?? null) {
208 $env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile;
209 }
210
211 if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) {
212 if ($email = self::getServiceAccountImpersonationEmail($serviceAccountImpersonationUrl)) {
213 $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $email;
214 }
215 }
216
217 $timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null;
218
219 return new ExecutableSource(
220 $credentialSource['executable']['command'],
221 $outputFile,
222 $timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env)
223 );
224 }
225
226 throw new InvalidArgumentException('Unable to determine credential source from json key.');
227 }
228
229 private static function getServiceAccountImpersonationEmail(string $serviceAccountImpersonationUrl): string|null
230 {
231 // Parse email from URL. The formal looks as follows:
232 // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
233 $regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
234 if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
235 return $matches['email'];
236 }
237
238 return null;
239 }
240
241 /**
242 * @param string $stsToken
243 * @param callable|null $httpHandler
244 *
245 * @return array<mixed> {
246 * A set of auth related metadata, containing the following
247 *
248 * @type string $access_token
249 * @type int $expires_at
250 * }
251 */
252 private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHandler = null): array
253 {
254 if (!isset($this->serviceAccountImpersonationUrl)) {
255 throw new InvalidArgumentException(
256 'service_account_impersonation_url must be set in JSON credentials.'
257 );
258 }
259 $request = new Request(
260 'POST',
261 $this->serviceAccountImpersonationUrl,
262 [
263 'Content-Type' => 'application/json',
264 'Authorization' => 'Bearer ' . $stsToken,
265 ],
266 (string) json_encode([
267 'lifetime' => sprintf('%ss', OAuth2::DEFAULT_EXPIRY_SECONDS),
268 'scope' => explode(' ', $this->auth->getScope()),
269 ]),
270 );
271 if (is_null($httpHandler)) {
272 $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());
273 }
274 $response = $httpHandler($request);
275 $body = json_decode((string) $response->getBody(), true);
276 return [
277 'access_token' => $body['accessToken'],
278 'expires_at' => strtotime($body['expireTime']),
279 ];
280 }
281
282 /**
283 * @param callable|null $httpHandler
284 * @param array<mixed> $headers [optional] Metrics headers to be inserted
285 * into the token endpoint request present.
286 *
287 * @return array<mixed> {
288 * A set of auth related metadata, containing the following
289 *
290 * @type string $access_token
291 * @type int $expires_at (impersonated service accounts only)
292 * @type int $expires_in (identity pool only)
293 * @type string $issued_token_type (identity pool only)
294 * @type string $token_type (identity pool only)
295 * }
296 */
297 public function fetchAuthToken(?callable $httpHandler = null, array $headers = [])
298 {
299 $stsToken = $this->auth->fetchAuthToken($httpHandler, $headers);
300
301 if (isset($this->serviceAccountImpersonationUrl)) {
302 return $this->lastImpersonatedAccessToken = $this->getImpersonatedAccessToken(
303 $stsToken['access_token'],
304 $httpHandler
305 );
306 }
307
308 return $stsToken;
309 }
310
311 /**
312 * Updates metadata with the authorization token.
313 *
314 * @param array<mixed> $metadata metadata hashmap
315 * @param string $authUri optional auth uri
316 * @param callable|null $httpHandler callback which delivers psr7 request
317 * @return array<mixed> updated metadata hashmap
318 */
319 public function updateMetadata(
320 $metadata,
321 $authUri = null,
322 ?callable $httpHandler = null
323 ) {
324 $metadata = $this->traitUpdateMetadata($metadata, $authUri, $httpHandler);
325
326 if ($this->enableRegionalAccessBoundary) {
327 $clientName = $this->serviceAccountImpersonationUrl
328 ? self::getServiceAccountImpersonationEmail($this->serviceAccountImpersonationUrl)
329 : null;
330
331 $metadata = $this->updateRegionalAccessBoundaryMetadata(
332 $metadata,
333 $this->buildRegionalAccessBoundaryLookupUrl($clientName),
334 $this->getUniverseDomain(),
335 $httpHandler,
336 );
337 }
338
339 return $metadata;
340 }
341
342 /**
343 * Get the cache token key for the credentials.
344 * The cache token key format depends on the type of source
345 * The format for the cache key one of the following:
346 * FetcherCacheKey.Scope.[ServiceAccount].[TokenType].[WorkforcePoolUserProject]
347 * FetcherCacheKey.Audience.[ServiceAccount].[TokenType].[WorkforcePoolUserProject]
348 *
349 * @return ?string;
350 */
351 public function getCacheKey(): ?string
352 {
353 $scopeOrAudience = $this->auth->getAudience();
354 if (!$scopeOrAudience) {
355 $scopeOrAudience = $this->auth->getScope();
356 }
357
358 return $this->auth->getSubjectTokenFetcher()->getCacheKey() .
359 '.' . $scopeOrAudience .
360 '.' . ($this->serviceAccountImpersonationUrl ?? '') .
361 '.' . ($this->auth->getSubjectTokenType() ?? '') .
362 '.' . ($this->workforcePoolUserProject ?? '');
363 }
364
365 public function getLastReceivedToken()
366 {
367 return $this->lastImpersonatedAccessToken ?? $this->auth->getLastReceivedToken();
368 }
369
370 /**
371 * Get the quota project used for this API request
372 *
373 * @return string|null
374 */
375 public function getQuotaProject()
376 {
377 return $this->quotaProject;
378 }
379
380 /**
381 * Get the universe domain used for this API request
382 *
383 * @return string
384 */
385 public function getUniverseDomain(): string
386 {
387 return $this->universeDomain;
388 }
389
390 /**
391 * Get the project ID.
392 *
393 * @param callable|null $httpHandler Callback which delivers psr7 request
394 * @param string|null $accessToken The access token to use to sign the blob. If
395 * provided, saves a call to the metadata server for a new access
396 * token. **Defaults to** `null`.
397 * @return string|null
398 */
399 public function getProjectId(?callable $httpHandler = null, ?string $accessToken = null)
400 {
401 if (isset($this->projectId)) {
402 return $this->projectId;
403 }
404
405 $projectNumber = $this->getProjectNumber() ?: $this->workforcePoolUserProject;
406 if (!$projectNumber) {
407 return null;
408 }
409
410 if (is_null($httpHandler)) {
411 $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());
412 }
413
414 $url = str_replace(
415 'UNIVERSE_DOMAIN',
416 $this->getUniverseDomain(),
417 sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber)
418 );
419
420 if (is_null($accessToken)) {
421 $accessToken = $this->fetchAuthToken($httpHandler)['access_token'];
422 }
423
424 $request = new Request('GET', $url, ['authorization' => 'Bearer ' . $accessToken]);
425 $response = $httpHandler($request);
426
427 $body = json_decode((string) $response->getBody(), true);
428 return $this->projectId = $body['projectId'];
429 }
430
431 private function getProjectNumber(): ?string
432 {
433 $parts = explode('/', $this->auth->getAudience());
434 $i = array_search('projects', $parts);
435 return $parts[$i + 1] ?? null;
436 }
437
438 private function isWorkforcePool(): bool
439 {
440 $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#';
441 return preg_match($regex, $this->auth->getAudience()) === 1;
442 }
443
444 /**
445 * Builds and returns the URL for the regional access boundary lookup API.
446 */
447 private function buildRegionalAccessBoundaryLookupUrl(string|null $clientName): string
448 {
449 if (null !== $clientName) {
450 return $this->traitBuildRegionalAccessBoundaryLookupUrl(serviceAccountEmail: $clientName);
451 }
452
453 // Try to parse as a workload identity pool.
454 // Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
455 $regex = '/projects\/([^\/]+)\/locations\/global\/workloadIdentityPools\/([^\/]+)/';
456 if (preg_match($regex, $this->auth->getAudience(), $matches)) {
457 [$_, $projectNumber, $poolId] = $matches;
458
459 return $this->traitBuildRegionalAccessBoundaryLookupUrl(
460 poolId: $poolId,
461 projectNumber: $projectNumber,
462 );
463 }
464
465 // If that fails, try to parse as a workforce pool.
466 // Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
467 if (preg_match('/locations\/[^\/]+\/workforcePools\/([^\/]+)/', $this->auth->getAudience(), $matches)) {
468 return $this->traitBuildRegionalAccessBoundaryLookupUrl(
469 poolId: $matches[1],
470 );
471 }
472
473 throw new LogicException('Invalid audience format');
474 }
475 }
476