$jsonKey JSON credentials as an associative array. */ public function __construct( $scope, array $jsonKey ) { if (!array_key_exists('type', $jsonKey)) { throw new InvalidArgumentException('json key is missing the type field'); } if ($jsonKey['type'] !== self::EXTERNAL_ACCOUNT_TYPE) { throw new InvalidArgumentException(sprintf( 'expected "%s" type but received "%s"', self::EXTERNAL_ACCOUNT_TYPE, $jsonKey['type'] )); } if (!array_key_exists('token_url', $jsonKey)) { throw new InvalidArgumentException( 'json key is missing the token_url field' ); } if (!array_key_exists('audience', $jsonKey)) { throw new InvalidArgumentException( 'json key is missing the audience field' ); } if (!array_key_exists('subject_token_type', $jsonKey)) { throw new InvalidArgumentException( 'json key is missing the subject_token_type field' ); } if (!array_key_exists('credential_source', $jsonKey)) { throw new InvalidArgumentException( 'json key is missing the credential_source field' ); } $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null; $this->quotaProject = $jsonKey['quota_project_id'] ?? null; $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null; $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; $this->auth = new OAuth2([ 'tokenCredentialUri' => $jsonKey['token_url'], 'audience' => $jsonKey['audience'], 'scope' => $scope, 'subjectTokenType' => $jsonKey['subject_token_type'], 'subjectTokenFetcher' => self::buildCredentialSource($jsonKey), 'additionalOptions' => $this->workforcePoolUserProject ? ['userProject' => $this->workforcePoolUserProject] : [], ]); if (!$this->isWorkforcePool() && $this->workforcePoolUserProject) { throw new InvalidArgumentException( 'workforce_pool_user_project should not be set for non-workforce pool credentials.' ); } } /** * @param array $jsonKey */ private static function buildCredentialSource(array $jsonKey): ExternalAccountCredentialSourceInterface { $credentialSource = $jsonKey['credential_source']; if (isset($credentialSource['file'])) { return new FileSource( $credentialSource['file'], $credentialSource['format']['type'] ?? null, $credentialSource['format']['subject_token_field_name'] ?? null ); } if ( isset($credentialSource['environment_id']) && 1 === preg_match('/^aws(\d+)$/', $credentialSource['environment_id'], $matches) ) { if ($matches[1] !== '1') { throw new InvalidArgumentException( "aws version \"$matches[1]\" is not supported in the current build." ); } if (!array_key_exists('regional_cred_verification_url', $credentialSource)) { throw new InvalidArgumentException( 'The regional_cred_verification_url field is required for aws1 credential source.' ); } return new AwsNativeSource( $jsonKey['audience'], $credentialSource['regional_cred_verification_url'], // $regionalCredVerificationUrl $credentialSource['region_url'] ?? null, // $regionUrl $credentialSource['url'] ?? null, // $securityCredentialsUrl $credentialSource['imdsv2_session_token_url'] ?? null, // $imdsV2TokenUrl ); } if (isset($credentialSource['url'])) { return new UrlSource( $credentialSource['url'], $credentialSource['format']['type'] ?? null, $credentialSource['format']['subject_token_field_name'] ?? null, $credentialSource['headers'] ?? null, ); } if (isset($credentialSource['executable'])) { if (!array_key_exists('command', $credentialSource['executable'])) { throw new InvalidArgumentException( 'executable source requires a command to be set in the JSON file.' ); } // Build command environment variables $env = [ 'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'], 'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'], // Always set to 0 because interactive mode is not supported. 'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0', ]; if ($outputFile = $credentialSource['executable']['output_file'] ?? null) { $env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile; } if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) { // Parse email from URL. The formal looks as follows: // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email']; } } $timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null; return new ExecutableSource( $credentialSource['executable']['command'], $outputFile, $timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env) ); } throw new InvalidArgumentException('Unable to determine credential source from json key.'); } /** * @param string $stsToken * @param callable|null $httpHandler * * @return array { * A set of auth related metadata, containing the following * * @type string $access_token * @type int $expires_at * } */ private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHandler = null): array { if (!isset($this->serviceAccountImpersonationUrl)) { throw new InvalidArgumentException( 'service_account_impersonation_url must be set in JSON credentials.' ); } $request = new Request( 'POST', $this->serviceAccountImpersonationUrl, [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $stsToken, ], (string) json_encode([ 'lifetime' => sprintf('%ss', OAuth2::DEFAULT_EXPIRY_SECONDS), 'scope' => explode(' ', $this->auth->getScope()), ]), ); if (is_null($httpHandler)) { $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); return [ 'access_token' => $body['accessToken'], 'expires_at' => strtotime($body['expireTime']), ]; } /** * @param callable|null $httpHandler * * @return array { * A set of auth related metadata, containing the following * * @type string $access_token * @type int $expires_at (impersonated service accounts only) * @type int $expires_in (identity pool only) * @type string $issued_token_type (identity pool only) * @type string $token_type (identity pool only) * } */ public function fetchAuthToken(?callable $httpHandler = null) { $stsToken = $this->auth->fetchAuthToken($httpHandler); if (isset($this->serviceAccountImpersonationUrl)) { return $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler); } return $stsToken; } /** * Get the cache token key for the credentials. * The cache token key format depends on the type of source * The format for the cache key one of the following: * FetcherCacheKey.Scope.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] * FetcherCacheKey.Audience.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] * * @return ?string; */ public function getCacheKey(): ?string { $scopeOrAudience = $this->auth->getAudience(); if (!$scopeOrAudience) { $scopeOrAudience = $this->auth->getScope(); } return $this->auth->getSubjectTokenFetcher()->getCacheKey() . '.' . $scopeOrAudience . '.' . ($this->serviceAccountImpersonationUrl ?? '') . '.' . ($this->auth->getSubjectTokenType() ?? '') . '.' . ($this->workforcePoolUserProject ?? ''); } public function getLastReceivedToken() { return $this->auth->getLastReceivedToken(); } /** * Get the quota project used for this API request * * @return string|null */ public function getQuotaProject() { return $this->quotaProject; } /** * Get the universe domain used for this API request * * @return string */ public function getUniverseDomain(): string { return $this->universeDomain; } /** * Get the project ID. * * @param callable|null $httpHandler Callback which delivers psr7 request * @param string|null $accessToken The access token to use to sign the blob. If * provided, saves a call to the metadata server for a new access * token. **Defaults to** `null`. * @return string|null */ public function getProjectId(?callable $httpHandler = null, ?string $accessToken = null) { if (isset($this->projectId)) { return $this->projectId; } $projectNumber = $this->getProjectNumber() ?: $this->workforcePoolUserProject; if (!$projectNumber) { return null; } if (is_null($httpHandler)) { $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } $url = str_replace( 'UNIVERSE_DOMAIN', $this->getUniverseDomain(), sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber) ); if (is_null($accessToken)) { $accessToken = $this->fetchAuthToken($httpHandler)['access_token']; } $request = new Request('GET', $url, ['authorization' => 'Bearer ' . $accessToken]); $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); return $this->projectId = $body['projectId']; } private function getProjectNumber(): ?string { $parts = explode('/', $this->auth->getAudience()); $i = array_search('projects', $parts); return $parts[$i + 1] ?? null; } private function isWorkforcePool(): bool { $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#'; return preg_match($regex, $this->auth->getAudience()) === 1; } }