From de766e956645dd114478be918363d06fd928b558 Mon Sep 17 00:00:00 2001 From: Matt Monroe Date: Mon, 28 Nov 2022 10:15:02 -0800 Subject: [PATCH] feat: add ImpersonatedServiceAccountCredentials (#421) --- src/Credentials/GCECredentials.php | 43 +----- .../ImpersonatedServiceAccountCredentials.php | 132 ++++++++++++++++++ src/CredentialsLoader.php | 8 +- src/IamSignerTrait.php | 67 +++++++++ tests/ApplicationDefaultCredentialsTest.php | 27 ++++ ...ersonatedServiceAccountCredentialsTest.php | 72 ++++++++++ .../application_default_credentials.json | 10 ++ 7 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 src/Credentials/ImpersonatedServiceAccountCredentials.php create mode 100644 src/IamSignerTrait.php create mode 100644 tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php create mode 100644 tests/fixtures5/.config/gcloud/application_default_credentials.json diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 4c883ad97..0a2c019de 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -22,6 +22,7 @@ use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\Iam; +use Google\Auth\IamSignerTrait; use Google\Auth\ProjectIdProviderInterface; use Google\Auth\SignBlobInterface; use GuzzleHttp\Exception\ClientException; @@ -60,6 +61,8 @@ class GCECredentials extends CredentialsLoader implements ProjectIdProviderInterface, GetQuotaProjectInterface { + use IamSignerTrait; + // phpcs:disable const cacheKey = 'GOOGLE_AUTH_PHP_GCE'; // phpcs:enable @@ -141,11 +144,6 @@ class GCECredentials extends CredentialsLoader implements */ private $projectId; - /** - * @var Iam|null - */ - private $iam; - /** * @var string */ @@ -451,41 +449,6 @@ public function getClientName(callable $httpHandler = null) return $this->clientName; } - /** - * Sign a string using the default service account private key. - * - * This implementation uses IAM's signBlob API. - * - * @see https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/signBlob SignBlob - * - * @param string $stringToSign The string to sign. - * @param bool $forceOpenSsl [optional] Does not apply to this credentials - * type. - * @param string $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 - */ - public function signBlob($stringToSign, $forceOpenSsl = false, $accessToken = null) - { - $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); - - // Providing a signer is useful for testing, but it's undocumented - // because it's not something a user would generally need to do. - $signer = $this->iam ?: new Iam($httpHandler); - - $email = $this->getClientName($httpHandler); - - if (is_null($accessToken)) { - $previousToken = $this->getLastReceivedToken(); - $accessToken = $previousToken - ? $previousToken['access_token'] - : $this->fetchAuthToken($httpHandler)['access_token']; - } - - return $signer->signBlob($email, $accessToken, $stringToSign); - } - /** * Fetch the default Project ID from compute engine. * diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php new file mode 100644 index 000000000..577fe2298 --- /dev/null +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -0,0 +1,132 @@ + $jsonKey JSON credential file path or JSON credentials + * as an associative array + */ + public function __construct( + $scope, + $jsonKey + ) { + if (is_string($jsonKey)) { + if (!file_exists($jsonKey)) { + throw new \InvalidArgumentException('file does not exist'); + } + $json = file_get_contents($jsonKey); + if (!$jsonKey = json_decode((string) $json, true)) { + throw new \LogicException('invalid json for auth config'); + } + } + if (!array_key_exists('service_account_impersonation_url', $jsonKey)) { + throw new \LogicException('json key is missing the service_account_impersonation_url field'); + } + if (!array_key_exists('source_credentials', $jsonKey)) { + throw new \LogicException('json key is missing the source_credentials field'); + } + + $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl($jsonKey['service_account_impersonation_url']); + + $this->sourceCredentials = new UserRefreshCredentials($scope, $jsonKey['source_credentials']); + } + + /** + * Helper function for extracting the Server Account Name from the URL saved in the account credentials file + * @param $serviceAccountImpersonationUrl string URL from the 'service_account_impersonation_url' field + * @return string Service account email or ID. + */ + private function getImpersonatedServiceAccountNameFromUrl(string $serviceAccountImpersonationUrl) + { + $fields = explode('/', $serviceAccountImpersonationUrl); + $lastField = end($fields); + $splitter = explode(':', $lastField); + return $splitter[0]; + } + + /** + * Get the client name from the keyfile + * + * In this implementation, it will return the issuers email from the oauth token. + * + * @param callable|null $unusedHttpHandler not used by this credentials type. + * @return string Token issuer email + */ + public function getClientName(callable $unusedHttpHandler = null) + { + return $this->impersonatedServiceAccountName; + } + + /** + * @param callable $httpHandler + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_in + * @type string $scope + * @type string $token_type + * @type string $id_token + * } + */ + public function fetchAuthToken(callable $httpHandler = null) + { + return $this->sourceCredentials->fetchAuthToken($httpHandler); + } + + /** + * @return string + */ + public function getCacheKey() + { + return $this->sourceCredentials->getCacheKey(); + } + + /** + * @return array + */ + public function getLastReceivedToken() + { + return $this->sourceCredentials->getLastReceivedToken(); + } +} diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 2ffe8d9de..d20f8e20a 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -17,6 +17,7 @@ namespace Google\Auth; +use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\InsecureCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; @@ -120,7 +121,7 @@ public static function fromWellKnownFile() * user-defined scopes exist, expressed either as an Array or as a * space-delimited string. * - * @return ServiceAccountCredentials|UserRefreshCredentials + * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials */ public static function makeCredentials( $scope, @@ -141,6 +142,11 @@ public static function makeCredentials( return new UserRefreshCredentials($anyScope, $jsonKey); } + if ($jsonKey['type'] == 'impersonated_service_account') { + $anyScope = $scope ?: $defaultScope; + return new ImpersonatedServiceAccountCredentials($anyScope, $jsonKey); + } + throw new \InvalidArgumentException('invalid value in the type field'); } diff --git a/src/IamSignerTrait.php b/src/IamSignerTrait.php new file mode 100644 index 000000000..9de18b3fd --- /dev/null +++ b/src/IamSignerTrait.php @@ -0,0 +1,67 @@ +iam ?: new Iam($httpHandler); + + $email = $this->getClientName($httpHandler); + + if (is_null($accessToken)) { + $previousToken = $this->getLastReceivedToken(); + $accessToken = $previousToken + ? $previousToken['access_token'] + : $this->fetchAuthToken($httpHandler)['access_token']; + } + + return $signer->signBlob($email, $accessToken, $stringToSign); + } +} diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 75b50697b..1ffb54b7e 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -159,6 +159,33 @@ public function testGceCredentials() $this->assertStringContainsString('a+user+scope', $tokenUri); } + public function testImpersonatedServiceAccountCredentials() + { + putenv('HOME=' . __DIR__ . '/fixtures5'); + $creds = ApplicationDefaultCredentials::getCredentials( + null, + null, + null, + null, + null, + 'a default scope' + ); + $this->assertInstanceOf( + 'Google\Auth\Credentials\ImpersonatedServiceAccountCredentials', + $creds); + + $this->assertEquals('service_account_name@namespace.iam.gserviceaccount.com', $creds->getClientName()); + + $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); + $sourceCredentialsProperty->setAccessible(true); + + // used default scope + $sourceCredentials = $sourceCredentialsProperty->getValue($creds); + $this->assertInstanceOf( + 'Google\Auth\Credentials\UserRefreshCredentials', + $sourceCredentials); + } + /** @runInSeparateProcess */ public function testUserRefreshCredentials() { diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php new file mode 100644 index 000000000..9eeb418cb --- /dev/null +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -0,0 +1,72 @@ + 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', + 'source_credentials' => [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ] + ]; + } + + public function testGetServiceAccountNameEmail() + { + $testJson = $this->createISACTestJson(); + $scope = ['scope/1', 'scope/2']; + $sa = new ImpersonatedServiceAccountCredentials( + $scope, + $testJson + ); + $this->assertEquals('test@test-project.iam.gserviceaccount.com', $sa->getClientName()); + } + + public function testGetServiceAccountNameID() + { + $testJson = $this->createISACTestJson(); + $testJson['service_account_impersonation_url'] = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/1234567890987654321:generateAccessToken'; + $scope = ['scope/1', 'scope/2']; + $sa = new ImpersonatedServiceAccountCredentials( + $scope, + $testJson + ); + $this->assertEquals('1234567890987654321', $sa->getClientName()); + } + + public function testErrorCredentials() + { + $testJson = $this->createISACTestJson(); + $scope = ['scope/1', 'scope/2']; + $this->expectException(LogicException::class); + new ImpersonatedServiceAccountCredentials($scope, $testJson['source_credentials']); + } +} diff --git a/tests/fixtures5/.config/gcloud/application_default_credentials.json b/tests/fixtures5/.config/gcloud/application_default_credentials.json new file mode 100644 index 000000000..8fb762c00 --- /dev/null +++ b/tests/fixtures5/.config/gcloud/application_default_credentials.json @@ -0,0 +1,10 @@ +{ + "type": "impersonated_service_account", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service_account_name@namespace.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "client_id": "client123", + "client_secret": "clientSecret123", + "refresh_token": "refreshToken123", + "type": "authorized_user" + } +}