<?php
/*
* Copyright 2023 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace Google\Auth\Credentials;
use Google\Auth\CredentialSource\AwsNativeSource;
use Google\Auth\CredentialSource\ExecutableSource;
use Google\Auth\CredentialSource\FileSource;
use Google\Auth\CredentialSource\UrlSource;
use Google\Auth\ExecutableHandler\ExecutableHandler;
use Google\Auth\ExternalAccountCredentialSourceInterface;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\GetQuotaProjectInterface;
use Google\Auth\GetUniverseDomainInterface;
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use Google\Auth\OAuth2;
use Google\Auth\ProjectIdProviderInterface;
use Google\Auth\UpdateMetadataInterface;
use Google\Auth\UpdateMetadataTrait;
use GuzzleHttp\Psr7\Request;
use InvalidArgumentException;
class ExternalAccountCredentials implements
FetchAuthTokenInterface,
UpdateMetadataInterface,
GetQuotaProjectInterface,
GetUniverseDomainInterface,
ProjectIdProviderInterface
{
use UpdateMetadataTrait;
private const EXTERNAL_ACCOUNT_TYPE = 'external_account';
private const CLOUD_RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.UNIVERSE_DOMAIN/v1/projects/%s';
private OAuth2 $auth;
private ?string $quotaProject;
private ?string $serviceAccountImpersonationUrl;
private ?string $workforcePoolUserProject;
private ?string $projectId;
private string $universeDomain;
/**
* @param string|string[] $scope The scope of the access request, expressed either as an array
* or as a space-delimited string.
* @param array<mixed> $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<mixed> $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\/(?<email>[^:]+):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<mixed> {
* 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
* @param array<mixed> $headers [optional] Metrics headers to be inserted
* into the token endpoint request present.
*
* @return array<mixed> {
* 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, array $headers = [])
{
$stsToken = $this->auth->fetchAuthToken($httpHandler, $headers);
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;
}
}
|