← All changes
|
includes/sdk/google/google/auth/src/Credentials/ExternalAccountCredentials.php
+105
-57
1.2.12
→
1.4.2
View file →
| @@ -14,25 +14,36 @@ | ||
| 14 | 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 15 | 15 | * See the License for the specific language governing permissions and |
| 16 | 16 | * limitations under the License. |
| 17 | 17 | */ |
| 18 | -namespace Dudlewebs\WPMCS\Google\Auth\Credentials; | |
| 18 | +namespace Dudlewebs\WPMCS\GCP\Google\Auth\Credentials; | |
| 19 | 19 | |
| 20 | -use Dudlewebs\WPMCS\Google\Auth\CredentialSource\AwsNativeSource; | |
| 21 | -use Dudlewebs\WPMCS\Google\Auth\CredentialSource\FileSource; | |
| 22 | -use Dudlewebs\WPMCS\Google\Auth\CredentialSource\UrlSource; | |
| 23 | -use Dudlewebs\WPMCS\Google\Auth\ExternalAccountCredentialSourceInterface; | |
| 24 | -use Dudlewebs\WPMCS\Google\Auth\FetchAuthTokenInterface; | |
| 25 | -use Dudlewebs\WPMCS\Google\Auth\GetQuotaProjectInterface; | |
| 26 | -use Dudlewebs\WPMCS\Google\Auth\GetUniverseDomainInterface; | |
| 27 | -use Dudlewebs\WPMCS\Google\Auth\HttpHandler\HttpClientCache; | |
| 28 | -use Dudlewebs\WPMCS\Google\Auth\HttpHandler\HttpHandlerFactory; | |
| 29 | -use Dudlewebs\WPMCS\Google\Auth\OAuth2; | |
| 30 | -use Dudlewebs\WPMCS\Google\Auth\ProjectIdProviderInterface; | |
| 31 | -use Dudlewebs\WPMCS\Google\Auth\UpdateMetadataInterface; | |
| 32 | -use Dudlewebs\WPMCS\Google\Auth\UpdateMetadataTrait; | |
| 33 | -use Dudlewebs\WPMCS\GuzzleHttp\Psr7\Request; | |
| 20 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialSource\AwsNativeSource; | |
| 21 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialSource\ExecutableSource; | |
| 22 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialSource\FileSource; | |
| 23 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialSource\UrlSource; | |
| 24 | +use Dudlewebs\WPMCS\GCP\Google\Auth\ExecutableHandler\ExecutableHandler; | |
| 25 | +use Dudlewebs\WPMCS\GCP\Google\Auth\ExternalAccountCredentialSourceInterface; | |
| 26 | +use Dudlewebs\WPMCS\GCP\Google\Auth\FetchAuthTokenInterface; | |
| 27 | +use Dudlewebs\WPMCS\GCP\Google\Auth\GetQuotaProjectInterface; | |
| 28 | +use Dudlewebs\WPMCS\GCP\Google\Auth\GetUniverseDomainInterface; | |
| 29 | +use Dudlewebs\WPMCS\GCP\Google\Auth\HttpHandler\HttpClientCache; | |
| 30 | +use Dudlewebs\WPMCS\GCP\Google\Auth\HttpHandler\HttpHandlerFactory; | |
| 31 | +use Dudlewebs\WPMCS\GCP\Google\Auth\OAuth2; | |
| 32 | +use Dudlewebs\WPMCS\GCP\Google\Auth\ProjectIdProviderInterface; | |
| 33 | +use Dudlewebs\WPMCS\GCP\Google\Auth\UpdateMetadataInterface; | |
| 34 | +use Dudlewebs\WPMCS\GCP\Google\Auth\UpdateMetadataTrait; | |
| 35 | +use Dudlewebs\WPMCS\GCP\GuzzleHttp\Psr7\Request; | |
| 34 | 36 | use InvalidArgumentException; |
| 37 | +/** | |
| 38 | + * **IMPORTANT**: | |
| 39 | + * This class does not validate the credential configuration. A security | |
| 40 | + * risk occurs when a credential configuration configured with malicious urls | |
| 41 | + * is used. | |
| 42 | + * When the credential configuration is accepted from an | |
| 43 | + * untrusted source, you should validate it before creating this class. | |
| 44 | + * @see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials | |
| 45 | + */ | |
| 35 | 46 | class ExternalAccountCredentials implements FetchAuthTokenInterface, UpdateMetadataInterface, GetQuotaProjectInterface, GetUniverseDomainInterface, ProjectIdProviderInterface |
| 36 | 47 | { |
| 37 | 48 | use UpdateMetadataTrait; |
| 38 | 49 | private const EXTERNAL_ACCOUNT_TYPE = 'external_account'; |
| @@ -41,8 +52,10 @@ | ||
| 41 | 52 | private ?string $quotaProject; |
| 42 | 53 | private ?string $serviceAccountImpersonationUrl; |
| 43 | 54 | private ?string $workforcePoolUserProject; |
| 44 | 55 | private ?string $projectId; |
| 56 | + /** @var array<mixed> */ | |
| 57 | + private ?array $lastImpersonatedAccessToken; | |
| 45 | 58 | private string $universeDomain; |
| 46 | 59 | /** |
| 47 | 60 | * @param string|string[] $scope The scope of the access request, expressed either as an array |
| 48 | 61 | * or as a space-delimited string. |
| @@ -49,29 +62,27 @@ | ||
| 49 | 62 | * @param array<mixed> $jsonKey JSON credentials as an associative array. |
| 50 | 63 | */ |
| 51 | 64 | public function __construct($scope, array $jsonKey) |
| 52 | 65 | { |
| 53 | - if (!array_key_exists('type', $jsonKey)) { | |
| 66 | + if (!\array_key_exists('type', $jsonKey)) { | |
| 54 | 67 | throw new InvalidArgumentException('json key is missing the type field'); |
| 55 | 68 | } |
| 56 | 69 | if ($jsonKey['type'] !== self::EXTERNAL_ACCOUNT_TYPE) { |
| 57 | - throw new InvalidArgumentException(sprintf('expected "%s" type but received "%s"', self::EXTERNAL_ACCOUNT_TYPE, $jsonKey['type'])); | |
| 70 | + throw new InvalidArgumentException(\sprintf('expected "%s" type but received "%s"', self::EXTERNAL_ACCOUNT_TYPE, $jsonKey['type'])); | |
| 58 | 71 | } |
| 59 | - if (!array_key_exists('token_url', $jsonKey)) { | |
| 72 | + if (!\array_key_exists('token_url', $jsonKey)) { | |
| 60 | 73 | throw new InvalidArgumentException('json key is missing the token_url field'); |
| 61 | 74 | } |
| 62 | - if (!array_key_exists('audience', $jsonKey)) { | |
| 75 | + if (!\array_key_exists('audience', $jsonKey)) { | |
| 63 | 76 | throw new InvalidArgumentException('json key is missing the audience field'); |
| 64 | 77 | } |
| 65 | - if (!array_key_exists('subject_token_type', $jsonKey)) { | |
| 78 | + if (!\array_key_exists('subject_token_type', $jsonKey)) { | |
| 66 | 79 | throw new InvalidArgumentException('json key is missing the subject_token_type field'); |
| 67 | 80 | } |
| 68 | - if (!array_key_exists('credential_source', $jsonKey)) { | |
| 81 | + if (!\array_key_exists('credential_source', $jsonKey)) { | |
| 69 | 82 | throw new InvalidArgumentException('json key is missing the credential_source field'); |
| 70 | 83 | } |
| 71 | - if (array_key_exists('service_account_impersonation_url', $jsonKey)) { | |
| 72 | - $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; | |
| 73 | - } | |
| 84 | + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null; | |
| 74 | 85 | $this->quotaProject = $jsonKey['quota_project_id'] ?? null; |
| 75 | 86 | $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null; |
| 76 | 87 | $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; |
| 77 | 88 | $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] : []]); |
| @@ -81,24 +92,21 @@ | ||
| 81 | 92 | } |
| 82 | 93 | /** |
| 83 | 94 | * @param array<mixed> $jsonKey |
| 84 | 95 | */ |
| 85 | - private static function buildCredentialSource(array $jsonKey): ExternalAccountCredentialSourceInterface | |
| 96 | + private static function buildCredentialSource(array $jsonKey) : ExternalAccountCredentialSourceInterface | |
| 86 | 97 | { |
| 87 | 98 | $credentialSource = $jsonKey['credential_source']; |
| 88 | 99 | if (isset($credentialSource['file'])) { |
| 89 | 100 | return new FileSource($credentialSource['file'], $credentialSource['format']['type'] ?? null, $credentialSource['format']['subject_token_field_name'] ?? null); |
| 90 | 101 | } |
| 91 | - if (isset($credentialSource['environment_id']) && 1 === preg_match('/^aws(\d+)$/', $credentialSource['environment_id'], $matches)) { | |
| 102 | + if (isset($credentialSource['environment_id']) && 1 === \preg_match('/^aws(\\d+)$/', $credentialSource['environment_id'], $matches)) { | |
| 92 | 103 | if ($matches[1] !== '1') { |
| 93 | 104 | throw new InvalidArgumentException("aws version \"{$matches[1]}\" is not supported in the current build."); |
| 94 | 105 | } |
| 95 | - if (!array_key_exists('regional_cred_verification_url', $credentialSource)) { | |
| 106 | + if (!\array_key_exists('regional_cred_verification_url', $credentialSource)) { | |
| 96 | 107 | throw new InvalidArgumentException('The regional_cred_verification_url field is required for aws1 credential source.'); |
| 97 | 108 | } |
| 98 | - if (!array_key_exists('audience', $jsonKey)) { | |
| 99 | - throw new InvalidArgumentException('aws1 credential source requires an audience to be set in the JSON file.'); | |
| 100 | - } | |
| 101 | 109 | return new AwsNativeSource( |
| 102 | 110 | $jsonKey['audience'], |
| 103 | 111 | $credentialSource['regional_cred_verification_url'], |
| 104 | 112 | // $regionalCredVerificationUrl |
| @@ -111,13 +119,38 @@ | ||
| 111 | 119 | } |
| 112 | 120 | if (isset($credentialSource['url'])) { |
| 113 | 121 | return new UrlSource($credentialSource['url'], $credentialSource['format']['type'] ?? null, $credentialSource['format']['subject_token_field_name'] ?? null, $credentialSource['headers'] ?? null); |
| 114 | 122 | } |
| 123 | + if (isset($credentialSource['executable'])) { | |
| 124 | + if (!\array_key_exists('command', $credentialSource['executable'])) { | |
| 125 | + throw new InvalidArgumentException('executable source requires a command to be set in the JSON file.'); | |
| 126 | + } | |
| 127 | + // Build command environment variables | |
| 128 | + $env = [ | |
| 129 | + 'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'], | |
| 130 | + 'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'], | |
| 131 | + // Always set to 0 because interactive mode is not supported. | |
| 132 | + 'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0', | |
| 133 | + ]; | |
| 134 | + if ($outputFile = $credentialSource['executable']['output_file'] ?? null) { | |
| 135 | + $env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile; | |
| 136 | + } | |
| 137 | + if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) { | |
| 138 | + // Parse email from URL. The formal looks as follows: | |
| 139 | + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken | |
| 140 | + $regex = '/serviceAccounts\\/(?<email>[^:]+):generateAccessToken$/'; | |
| 141 | + if (\preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { | |
| 142 | + $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email']; | |
| 143 | + } | |
| 144 | + } | |
| 145 | + $timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null; | |
| 146 | + return new ExecutableSource($credentialSource['executable']['command'], $outputFile, $timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env)); | |
| 147 | + } | |
| 115 | 148 | throw new InvalidArgumentException('Unable to determine credential source from json key.'); |
| 116 | 149 | } |
| 117 | 150 | /** |
| 118 | 151 | * @param string $stsToken |
| 119 | - * @param callable $httpHandler | |
| 152 | + * @param callable|null $httpHandler | |
| 120 | 153 | * |
| 121 | 154 | * @return array<mixed> { |
| 122 | 155 | * A set of auth related metadata, containing the following |
| 123 | 156 | * |
| @@ -124,23 +157,25 @@ | ||
| 124 | 157 | * @type string $access_token |
| 125 | 158 | * @type int $expires_at |
| 126 | 159 | * } |
| 127 | 160 | */ |
| 128 | - private function getImpersonatedAccessToken(string $stsToken, callable $httpHandler = null): array | |
| 161 | + private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHandler = null) : array | |
| 129 | 162 | { |
| 130 | 163 | if (!isset($this->serviceAccountImpersonationUrl)) { |
| 131 | 164 | throw new InvalidArgumentException('service_account_impersonation_url must be set in JSON credentials.'); |
| 132 | 165 | } |
| 133 | - $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())])); | |
| 134 | - if (is_null($httpHandler)) { | |
| 166 | + $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())])); | |
| 167 | + if (\is_null($httpHandler)) { | |
| 135 | 168 | $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); |
| 136 | 169 | } |
| 137 | 170 | $response = $httpHandler($request); |
| 138 | - $body = json_decode((string) $response->getBody(), \true); | |
| 139 | - return ['access_token' => $body['accessToken'], 'expires_at' => strtotime($body['expireTime'])]; | |
| 171 | + $body = \json_decode((string) $response->getBody(), \true); | |
| 172 | + return ['access_token' => $body['accessToken'], 'expires_at' => \strtotime($body['expireTime'])]; | |
| 140 | 173 | } |
| 141 | 174 | /** |
| 142 | - * @param callable $httpHandler | |
| 175 | + * @param callable|null $httpHandler | |
| 176 | + * @param array<mixed> $headers [optional] Metrics headers to be inserted | |
| 177 | + * into the token endpoint request present. | |
| 143 | 178 | * |
| 144 | 179 | * @return array<mixed> { |
| 145 | 180 | * A set of auth related metadata, containing the following |
| 146 | 181 | * |
| @@ -150,23 +185,36 @@ | ||
| 150 | 185 | * @type string $issued_token_type (identity pool only) |
| 151 | 186 | * @type string $token_type (identity pool only) |
| 152 | 187 | * } |
| 153 | 188 | */ |
| 154 | - public function fetchAuthToken(callable $httpHandler = null) | |
| 189 | + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) | |
| 155 | 190 | { |
| 156 | - $stsToken = $this->auth->fetchAuthToken($httpHandler); | |
| 191 | + $stsToken = $this->auth->fetchAuthToken($httpHandler, $headers); | |
| 157 | 192 | if (isset($this->serviceAccountImpersonationUrl)) { |
| 158 | - return $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler); | |
| 193 | + return $this->lastImpersonatedAccessToken = $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler); | |
| 159 | 194 | } |
| 160 | 195 | return $stsToken; |
| 161 | 196 | } |
| 162 | - public function getCacheKey() | |
| 197 | + /** | |
| 198 | + * Get the cache token key for the credentials. | |
| 199 | + * The cache token key format depends on the type of source | |
| 200 | + * The format for the cache key one of the following: | |
| 201 | + * FetcherCacheKey.Scope.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] | |
| 202 | + * FetcherCacheKey.Audience.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] | |
| 203 | + * | |
| 204 | + * @return ?string; | |
| 205 | + */ | |
| 206 | + public function getCacheKey() : ?string | |
| 163 | 207 | { |
| 164 | - return $this->auth->getCacheKey(); | |
| 208 | + $scopeOrAudience = $this->auth->getAudience(); | |
| 209 | + if (!$scopeOrAudience) { | |
| 210 | + $scopeOrAudience = $this->auth->getScope(); | |
| 211 | + } | |
| 212 | + return $this->auth->getSubjectTokenFetcher()->getCacheKey() . '.' . $scopeOrAudience . '.' . ($this->serviceAccountImpersonationUrl ?? '') . '.' . ($this->auth->getSubjectTokenType() ?? '') . '.' . ($this->workforcePoolUserProject ?? ''); | |
| 165 | 213 | } |
| 166 | 214 | public function getLastReceivedToken() |
| 167 | 215 | { |
| 168 | - return $this->auth->getLastReceivedToken(); | |
| 216 | + return $this->lastImpersonatedAccessToken ?? $this->auth->getLastReceivedToken(); | |
| 169 | 217 | } |
| 170 | 218 | /** |
| 171 | 219 | * Get the quota project used for this API request |
| 172 | 220 | * |
| @@ -180,9 +228,9 @@ | ||
| 180 | 228 | * Get the universe domain used for this API request |
| 181 | 229 | * |
| 182 | 230 | * @return string |
| 183 | 231 | */ |
| 184 | - public function getUniverseDomain(): string | |
| 232 | + public function getUniverseDomain() : string | |
| 185 | 233 | { |
| 186 | 234 | return $this->universeDomain; |
| 187 | 235 | } |
| 188 | 236 | /** |
| @@ -187,15 +235,15 @@ | ||
| 187 | 235 | } |
| 188 | 236 | /** |
| 189 | 237 | * Get the project ID. |
| 190 | 238 | * |
| 191 | - * @param callable $httpHandler Callback which delivers psr7 request | |
| 192 | - * @param string $accessToken The access token to use to sign the blob. If | |
| 239 | + * @param callable|null $httpHandler Callback which delivers psr7 request | |
| 240 | + * @param string|null $accessToken The access token to use to sign the blob. If | |
| 193 | 241 | * provided, saves a call to the metadata server for a new access |
| 194 | 242 | * token. **Defaults to** `null`. |
| 195 | 243 | * @return string|null |
| 196 | 244 | */ |
| 197 | - public function getProjectId(callable $httpHandler = null, string $accessToken = null) | |
| 245 | + public function getProjectId(?callable $httpHandler = null, ?string $accessToken = null) | |
| 198 | 246 | { |
| 199 | 247 | if (isset($this->projectId)) { |
| 200 | 248 | return $this->projectId; |
| 201 | 249 | } |
| @@ -202,28 +250,28 @@ | ||
| 202 | 250 | $projectNumber = $this->getProjectNumber() ?: $this->workforcePoolUserProject; |
| 203 | 251 | if (!$projectNumber) { |
| 204 | 252 | return null; |
| 205 | 253 | } |
| 206 | - if (is_null($httpHandler)) { | |
| 254 | + if (\is_null($httpHandler)) { | |
| 207 | 255 | $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); |
| 208 | 256 | } |
| 209 | - $url = str_replace('UNIVERSE_DOMAIN', $this->getUniverseDomain(), sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber)); | |
| 210 | - if (is_null($accessToken)) { | |
| 257 | + $url = \str_replace('UNIVERSE_DOMAIN', $this->getUniverseDomain(), \sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber)); | |
| 258 | + if (\is_null($accessToken)) { | |
| 211 | 259 | $accessToken = $this->fetchAuthToken($httpHandler)['access_token']; |
| 212 | 260 | } |
| 213 | 261 | $request = new Request('GET', $url, ['authorization' => 'Bearer ' . $accessToken]); |
| 214 | 262 | $response = $httpHandler($request); |
| 215 | - $body = json_decode((string) $response->getBody(), \true); | |
| 263 | + $body = \json_decode((string) $response->getBody(), \true); | |
| 216 | 264 | return $this->projectId = $body['projectId']; |
| 217 | 265 | } |
| 218 | - private function getProjectNumber(): ?string | |
| 266 | + private function getProjectNumber() : ?string | |
| 219 | 267 | { |
| 220 | - $parts = explode('/', $this->auth->getAudience()); | |
| 221 | - $i = array_search('projects', $parts); | |
| 268 | + $parts = \explode('/', $this->auth->getAudience()); | |
| 269 | + $i = \array_search('projects', $parts); | |
| 222 | 270 | return $parts[$i + 1] ?? null; |
| 223 | 271 | } |
| 224 | - private function isWorkforcePool(): bool | |
| 272 | + private function isWorkforcePool() : bool | |
| 225 | 273 | { |
| 226 | - $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#'; | |
| 227 | - return preg_match($regex, $this->auth->getAudience()) === 1; | |
| 274 | + $regex = '#//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/#'; | |
| 275 | + return \preg_match($regex, $this->auth->getAudience()) === 1; | |
| 228 | 276 | } |
| 229 | 277 | } |