← All changes
|
includes/sdk/google/google/auth/src/Credentials/ImpersonatedServiceAccountCredentials.php
+142
-35
1.0.0
→
1.4.2
View file →
| @@ -14,52 +14,119 @@ | ||
| 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\CredentialsLoader; | |
| 21 | -use Dudlewebs\WPMCS\Google\Auth\IamSignerTrait; | |
| 22 | -use Dudlewebs\WPMCS\Google\Auth\SignBlobInterface; | |
| 23 | -class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface | |
| 20 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CacheTrait; | |
| 21 | +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialsLoader; | |
| 22 | +use Dudlewebs\WPMCS\GCP\Google\Auth\FetchAuthTokenInterface; | |
| 23 | +use Dudlewebs\WPMCS\GCP\Google\Auth\GetUniverseDomainInterface; | |
| 24 | +use Dudlewebs\WPMCS\GCP\Google\Auth\HttpHandler\HttpClientCache; | |
| 25 | +use Dudlewebs\WPMCS\GCP\Google\Auth\HttpHandler\HttpHandlerFactory; | |
| 26 | +use Dudlewebs\WPMCS\GCP\Google\Auth\IamSignerTrait; | |
| 27 | +use Dudlewebs\WPMCS\GCP\Google\Auth\SignBlobInterface; | |
| 28 | +use Dudlewebs\WPMCS\GCP\GuzzleHttp\Psr7\Request; | |
| 29 | +use InvalidArgumentException; | |
| 30 | +use LogicException; | |
| 31 | +/** | |
| 32 | + * **IMPORTANT**: | |
| 33 | + * This class does not validate the credential configuration. A security | |
| 34 | + * risk occurs when a credential configuration configured with malicious urls | |
| 35 | + * is used. | |
| 36 | + * When the credential configuration is accepted from an | |
| 37 | + * untrusted source, you should validate it before creating this class. | |
| 38 | + * @see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials | |
| 39 | + */ | |
| 40 | +class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface, GetUniverseDomainInterface | |
| 24 | 41 | { |
| 42 | + use CacheTrait; | |
| 25 | 43 | use IamSignerTrait; |
| 44 | + private const CRED_TYPE = 'imp'; | |
| 45 | + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; | |
| 46 | + private const ID_TOKEN_IMPERSONATION_URL = 'https://iamcredentials.UNIVERSE_DOMAIN/v1/projects/-/serviceAccounts/%s:generateIdToken'; | |
| 26 | 47 | /** |
| 27 | 48 | * @var string |
| 28 | 49 | */ |
| 29 | 50 | protected $impersonatedServiceAccountName; |
| 51 | + protected FetchAuthTokenInterface $sourceCredentials; | |
| 52 | + private string $serviceAccountImpersonationUrl; | |
| 30 | 53 | /** |
| 31 | - * @var UserRefreshCredentials | |
| 54 | + * @var string[] | |
| 32 | 55 | */ |
| 33 | - protected $sourceCredentials; | |
| 56 | + private array $delegates; | |
| 34 | 57 | /** |
| 58 | + * @var string|string[] | |
| 59 | + */ | |
| 60 | + private string|array $targetScope; | |
| 61 | + private int $lifetime; | |
| 62 | + /** | |
| 63 | + * @var array<mixed>|null | |
| 64 | + */ | |
| 65 | + protected array|null $lastReceivedToken = null; | |
| 66 | + /** | |
| 35 | 67 | * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that |
| 36 | - * has be created with the --impersonated-service-account flag. | |
| 68 | + * has be created with the --impersonate-service-account flag. | |
| 37 | 69 | * |
| 38 | - * @param string|string[] $scope The scope of the access request, expressed either as an | |
| 39 | - * array or as a space-delimited string. | |
| 40 | - * @param string|array<mixed> $jsonKey JSON credential file path or JSON credentials | |
| 41 | - * as an associative array. | |
| 70 | + * @param string|string[]|null $scope The scope of the access request, expressed either as an | |
| 71 | + * array or as a space-delimited string. | |
| 72 | + * @param string|array<mixed> $jsonKey JSON credential file path or JSON array credentials { | |
| 73 | + * JSON credentials as an associative array. | |
| 74 | + * | |
| 75 | + * @type string $service_account_impersonation_url The URL to the service account | |
| 76 | + * @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate | |
| 77 | + * @type int $lifetime The lifetime of the impersonated credentials | |
| 78 | + * @type string[] $delegates The delegates to impersonate | |
| 79 | + * } | |
| 80 | + * @param string|null $targetAudience The audience to request an ID token. | |
| 81 | + * @param string|string[]|null $defaultScope The scopes to be used if no "scopes" field exists | |
| 82 | + * in the `$jsonKey`. | |
| 42 | 83 | */ |
| 43 | - public function __construct($scope, $jsonKey) | |
| 84 | + public function __construct(string|array|null $scope, string|array $jsonKey, private ?string $targetAudience = null, string|array|null $defaultScope = null) | |
| 44 | 85 | { |
| 45 | - if (is_string($jsonKey)) { | |
| 46 | - if (!file_exists($jsonKey)) { | |
| 47 | - throw new \InvalidArgumentException('file does not exist'); | |
| 86 | + if (\is_string($jsonKey)) { | |
| 87 | + if (!\file_exists($jsonKey)) { | |
| 88 | + throw new InvalidArgumentException('file does not exist'); | |
| 48 | 89 | } |
| 49 | - $json = file_get_contents($jsonKey); | |
| 50 | - if (!$jsonKey = json_decode((string) $json, \true)) { | |
| 51 | - throw new \LogicException('invalid json for auth config'); | |
| 90 | + $json = \file_get_contents($jsonKey); | |
| 91 | + if (!($jsonKey = \json_decode((string) $json, \true))) { | |
| 92 | + throw new LogicException('invalid json for auth config'); | |
| 52 | 93 | } |
| 53 | 94 | } |
| 54 | - if (!array_key_exists('service_account_impersonation_url', $jsonKey)) { | |
| 55 | - throw new \LogicException('json key is missing the service_account_impersonation_url field'); | |
| 95 | + if (!\array_key_exists('service_account_impersonation_url', $jsonKey)) { | |
| 96 | + throw new LogicException('json key is missing the service_account_impersonation_url field'); | |
| 56 | 97 | } |
| 57 | - if (!array_key_exists('source_credentials', $jsonKey)) { | |
| 58 | - throw new \LogicException('json key is missing the source_credentials field'); | |
| 98 | + if (!\array_key_exists('source_credentials', $jsonKey)) { | |
| 99 | + throw new LogicException('json key is missing the source_credentials field'); | |
| 59 | 100 | } |
| 60 | - $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl($jsonKey['service_account_impersonation_url']); | |
| 61 | - $this->sourceCredentials = new UserRefreshCredentials($scope, $jsonKey['source_credentials']); | |
| 101 | + $jsonKeyScope = $jsonKey['scopes'] ?? null; | |
| 102 | + $scope = ($scope ?: $jsonKeyScope) ?: $defaultScope; | |
| 103 | + if ($scope && $targetAudience) { | |
| 104 | + throw new InvalidArgumentException('Scope and targetAudience cannot both be supplied'); | |
| 105 | + } | |
| 106 | + if (\is_array($jsonKey['source_credentials'])) { | |
| 107 | + if (!\array_key_exists('type', $jsonKey['source_credentials'])) { | |
| 108 | + throw new InvalidArgumentException('json key source credentials are missing the type field'); | |
| 109 | + } | |
| 110 | + if ($targetAudience !== null && $jsonKey['source_credentials']['type'] === 'service_account') { | |
| 111 | + // Service account tokens MUST request a scope, and as this token is only used to impersonate | |
| 112 | + // an ID token, the narrowest scope we can request is `iam`. | |
| 113 | + $scope = self::IAM_SCOPE; | |
| 114 | + } | |
| 115 | + $jsonKey['source_credentials'] = match ($jsonKey['source_credentials']['type'] ?? null) { | |
| 116 | + // Do not pass $defaultScope to ServiceAccountCredentials | |
| 117 | + 'service_account' => new ServiceAccountCredentials($scope, $jsonKey['source_credentials']), | |
| 118 | + 'authorized_user' => new UserRefreshCredentials($scope, $jsonKey['source_credentials']), | |
| 119 | + 'external_account' => new ExternalAccountCredentials($scope, $jsonKey['source_credentials']), | |
| 120 | + default => throw new \InvalidArgumentException('invalid value in the type field'), | |
| 121 | + }; | |
| 122 | + } | |
| 123 | + $this->targetScope = $scope ?? []; | |
| 124 | + $this->lifetime = $jsonKey['lifetime'] ?? 3600; | |
| 125 | + $this->delegates = $jsonKey['delegates'] ?? []; | |
| 126 | + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; | |
| 127 | + $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl($this->serviceAccountImpersonationUrl); | |
| 128 | + $this->sourceCredentials = $jsonKey['source_credentials']; | |
| 62 | 129 | } |
| 63 | 130 | /** |
| 64 | 131 | * Helper function for extracting the Server Account Name from the URL saved in the account |
| 65 | 132 | * credentials file. |
| @@ -66,13 +133,13 @@ | ||
| 66 | 133 | * |
| 67 | 134 | * @param $serviceAccountImpersonationUrl string URL from "service_account_impersonation_url" |
| 68 | 135 | * @return string Service account email or ID. |
| 69 | 136 | */ |
| 70 | - private function getImpersonatedServiceAccountNameFromUrl(string $serviceAccountImpersonationUrl): string | |
| 137 | + private function getImpersonatedServiceAccountNameFromUrl(string $serviceAccountImpersonationUrl) : string | |
| 71 | 138 | { |
| 72 | - $fields = explode('/', $serviceAccountImpersonationUrl); | |
| 73 | - $lastField = end($fields); | |
| 74 | - $splitter = explode(':', $lastField); | |
| 139 | + $fields = \explode('/', $serviceAccountImpersonationUrl); | |
| 140 | + $lastField = \end($fields); | |
| 141 | + $splitter = \explode(':', $lastField); | |
| 75 | 142 | return $splitter[0]; |
| 76 | 143 | } |
| 77 | 144 | /** |
| 78 | 145 | * Get the client name from the keyfile |
| @@ -81,14 +148,14 @@ | ||
| 81 | 148 | * |
| 82 | 149 | * @param callable|null $unusedHttpHandler not used by this credentials type. |
| 83 | 150 | * @return string Token issuer email |
| 84 | 151 | */ |
| 85 | - public function getClientName(callable $unusedHttpHandler = null) | |
| 152 | + public function getClientName(?callable $unusedHttpHandler = null) | |
| 86 | 153 | { |
| 87 | 154 | return $this->impersonatedServiceAccountName; |
| 88 | 155 | } |
| 89 | 156 | /** |
| 90 | - * @param callable $httpHandler | |
| 157 | + * @param callable|null $httpHandler | |
| 91 | 158 | * |
| 92 | 159 | * @return array<mixed> { |
| 93 | 160 | * A set of auth related metadata, containing the following |
| 94 | 161 | * |
| @@ -98,18 +165,46 @@ | ||
| 98 | 165 | * @type string $token_type |
| 99 | 166 | * @type string $id_token |
| 100 | 167 | * } |
| 101 | 168 | */ |
| 102 | - public function fetchAuthToken(callable $httpHandler = null) | |
| 169 | + public function fetchAuthToken(?callable $httpHandler = null) | |
| 103 | 170 | { |
| 104 | - return $this->sourceCredentials->fetchAuthToken($httpHandler); | |
| 171 | + $httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient()); | |
| 172 | + // The FetchAuthTokenInterface technically does not have a "headers" argument, but all of | |
| 173 | + // the implementations do. Additionally, passing in more parameters than the function has | |
| 174 | + // defined is allowed in PHP. So we'll just ignore the phpstan error here. | |
| 175 | + // @phpstan-ignore-next-line | |
| 176 | + $authToken = $this->sourceCredentials->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], 'at')); | |
| 177 | + $headers = $this->applyTokenEndpointMetrics(['Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Authorization' => \sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token'])], $this->isIdTokenRequest() ? 'it' : 'at'); | |
| 178 | + $body = match ($this->isIdTokenRequest()) { | |
| 179 | + \true => ['audience' => $this->targetAudience, 'includeEmail' => \true], | |
| 180 | + \false => ['scope' => $this->targetScope, 'delegates' => $this->delegates, 'lifetime' => \sprintf('%ss', $this->lifetime)], | |
| 181 | + }; | |
| 182 | + $url = $this->serviceAccountImpersonationUrl; | |
| 183 | + if ($this->isIdTokenRequest()) { | |
| 184 | + $regex = '/serviceAccounts\\/(?<email>[^:]+):generateAccessToken$/'; | |
| 185 | + if (!\preg_match($regex, $url, $matches)) { | |
| 186 | + throw new InvalidArgumentException('Invalid service account impersonation URL - unable to parse service account email'); | |
| 187 | + } | |
| 188 | + $url = \str_replace('UNIVERSE_DOMAIN', $this->getUniverseDomain(), \sprintf(self::ID_TOKEN_IMPERSONATION_URL, $matches['email'])); | |
| 189 | + } | |
| 190 | + $request = new Request('POST', $url, $headers, (string) \json_encode($body)); | |
| 191 | + $response = $httpHandler($request); | |
| 192 | + $body = \json_decode((string) $response->getBody(), \true); | |
| 193 | + return $this->lastReceivedToken = match ($this->isIdTokenRequest()) { | |
| 194 | + \true => ['id_token' => $body['token']], | |
| 195 | + \false => ['access_token' => $body['accessToken'], 'expires_at' => \strtotime($body['expireTime'])], | |
| 196 | + }; | |
| 105 | 197 | } |
| 106 | 198 | /** |
| 199 | + * Returns the Cache Key for the credentials | |
| 200 | + * The cache key is the same as the UserRefreshCredentials class | |
| 201 | + * | |
| 107 | 202 | * @return string |
| 108 | 203 | */ |
| 109 | 204 | public function getCacheKey() |
| 110 | 205 | { |
| 111 | - return $this->sourceCredentials->getCacheKey(); | |
| 206 | + return $this->getFullCacheKey($this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey()); | |
| 112 | 207 | } |
| 113 | 208 | /** |
| 114 | 209 | * @return array<mixed> |
| 115 | 210 | */ |
| @@ -114,7 +209,19 @@ | ||
| 114 | 209 | * @return array<mixed> |
| 115 | 210 | */ |
| 116 | 211 | public function getLastReceivedToken() |
| 117 | 212 | { |
| 118 | - return $this->sourceCredentials->getLastReceivedToken(); | |
| 213 | + return $this->lastReceivedToken; | |
| 214 | + } | |
| 215 | + protected function getCredType() : string | |
| 216 | + { | |
| 217 | + return self::CRED_TYPE; | |
| 218 | + } | |
| 219 | + private function isIdTokenRequest() : bool | |
| 220 | + { | |
| 221 | + return !\is_null($this->targetAudience); | |
| 222 | + } | |
| 223 | + public function getUniverseDomain() : string | |
| 224 | + { | |
| 225 | + return $this->sourceCredentials instanceof GetUniverseDomainInterface ? $this->sourceCredentials->getUniverseDomain() : self::DEFAULT_UNIVERSE_DOMAIN; | |
| 119 | 226 | } |
| 120 | 227 | } |