From 22d07e21ced54adf881b3897131652248ba54dc3 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 09:15:15 -0700 Subject: [PATCH 01/21] WIP --- .../ImpersonatedServiceAccountCredentials.php | 50 +++++++++- src/CredentialsLoader.php | 1 + ...ersonatedServiceAccountCredentialsTest.php | 95 +++++++++++++------ 3 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 5d3522827..a2b978832 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -18,12 +18,16 @@ namespace Google\Auth\Credentials; +use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; +use GuzzleHttp\Psr7\Request; class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface { + use CacheTrait; use IamSignerTrait; private const CRED_TYPE = 'imp'; @@ -38,9 +42,17 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements */ protected $sourceCredentials; + private string $serviceAccountImpersonationUrl; + + private array $delegates; + + private string|array $targetScope; + + private int $lifetime; + /** * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that - * has be created with the --impersonated-service-account flag. + * has be created with the --impersonate-service-account flag. * * @param string|string[] $scope The scope of the access request, expressed either as an * array or as a space-delimited string. @@ -69,8 +81,13 @@ public function __construct( throw new \LogicException('json key is missing the source_credentials field'); } + $this->targetScope = $scope ?? []; + $this->lifetime = $jsonKey['lifetime'] ?? 3600; + $this->delegates = $jsonKey['delegates'] ?? []; + + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl( - $jsonKey['service_account_impersonation_url'] + $this->serviceAccountImpersonationUrl ); $this->sourceCredentials = new UserRefreshCredentials( @@ -123,11 +140,34 @@ public function getClientName(callable $unusedHttpHandler = null) */ public function fetchAuthToken(callable $httpHandler = null) { + $httpHandler = $httpHandler ?? HttpHandlerFactory::build(); + // We don't support id token endpoint requests as of now for Impersonated Cred - return $this->sourceCredentials->fetchAuthToken( + $authToken = $this->sourceCredentials->fetchAuthToken( $httpHandler, $this->applyTokenEndpointMetrics([], 'at') ); + + $request = new Request( + 'POST', + $this->serviceAccountImpersonationUrl, + [ + 'Authorization' => 'Bearer ' . $authToken['access_token'], + 'Content-Type' => 'application/json', + ], + json_encode([ + 'scope' => $this->targetScope, + 'delegates' => $this->delegates, + 'lifetime' => sprintf('%ss', $this->lifetime), + ]) + ); + + $response = $httpHandler($request); + $body = json_decode((string) $response->getBody(), true); + return [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ]; } /** @@ -138,7 +178,9 @@ public function fetchAuthToken(callable $httpHandler = null) */ public function getCacheKey() { - return $this->sourceCredentials->getCacheKey(); + return $this->getFullCacheKey( + $this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey() + ); } /** diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 746b957a9..c0a798fce 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -85,6 +85,7 @@ public static function fromEnv() throw new \DomainException(self::unableToReadEnv($cause)); } $jsonKey = file_get_contents($path); + return json_decode((string) $jsonKey, true); } diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 9eeb418cb..bbc755acb 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -19,13 +19,68 @@ namespace Google\Auth\Tests\Credentials; use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; +use Google\Auth\Credentials\ServiceAccountCredentials; +use Google\Auth\Credentials\UserRefreshCredentials; use LogicException; use PHPUnit\Framework\TestCase; +use ReflectionClass; class ImpersonatedServiceAccountCredentialsTest extends TestCase { + private const SCOPE = ['scope/1', 'scope/2']; + + /** + * @dataProvider provideServiceAccountImpersonationJson + */ + public function testGetServiceAccountNameEmail(array $testJson) + { + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson); + $this->assertEquals('test@test-project.iam.gserviceaccount.com', $creds->getClientName()); + } + + /** + * @dataProvider provideServiceAccountImpersonationJson + */ + public function testGetServiceAccountNameID(array $testJson) + { + $testJson['service_account_impersonation_url'] = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/1234567890987654321:generateAccessToken'; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson); + $this->assertEquals('1234567890987654321', $creds->getClientName()); + } + + /** + * @dataProvider provideServiceAccountImpersonationJson + */ + public function testErrorCredentials(array $testJson) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('json key is missing the service_account_impersonation_url field'); + + new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson['source_credentials']); + } + + /** + * @dataProvider provideServiceAccountImpersonationJson + */ + public function testSourceCredentialsFromJsonFiles(array $testJson, string $credClass) + { + $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $testJson); + + $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); + $sourceCredentialsProperty->setAccessible(true); + $this->assertInstanceOf($credClass, $sourceCredentialsProperty->getValue($creds)); + } + + public function provideServiceAccountImpersonationJson() + { + return [ + [$this->createUserISACTestJson(), UserRefreshCredentials::class], + [$this->createSAISACTestJson(), ServiceAccountCredentials::class], + ]; + } + // Creates a standard JSON auth object for testing. - private function createISACTestJson() + private function createUserISACTestJson() { return [ 'type' => 'impersonated_service_account', @@ -39,34 +94,18 @@ private function createISACTestJson() ]; } - 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() + // Creates a standard JSON auth object for testing. + private function createSAISACTestJson() { - $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()); + return [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', + 'source_credentials' => [ + 'client_email' => 'clientemail@clientemail.com', + 'private_key' => 'privatekey123', + 'type' => 'service_account', + ] + ]; } - public function testErrorCredentials() - { - $testJson = $this->createISACTestJson(); - $scope = ['scope/1', 'scope/2']; - $this->expectException(LogicException::class); - new ImpersonatedServiceAccountCredentials($scope, $testJson['source_credentials']); - } } From 203722d4d075b1adbb2718acd21dc6444f9189b9 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 09:58:51 -0700 Subject: [PATCH 02/21] open up jsonKey to any credential class --- .../ImpersonatedServiceAccountCredentials.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index a2b978832..9ba5cdb25 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -56,8 +56,14 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements * * @param string|string[] $scope The scope of the access request, expressed either as an * array or as a space-delimited string. - * @param string|array $jsonKey JSON credential file path or JSON credentials - * as an associative array. + * @param string|array $jsonKey JSON credential file path or JSON array credentials { + * JSON credentials as an associative array. + * + * @type string $service_account_impersonation_url The URL to the service account + * @type string|FetchAuthTokenCredentials $source_credentials The source credentials to impersonate + * @type int $lifetime The lifetime of the impersonated credentials + * @type string[] $delegates The delegates to impersonate + * } */ public function __construct( $scope, @@ -90,10 +96,9 @@ public function __construct( $this->serviceAccountImpersonationUrl ); - $this->sourceCredentials = new UserRefreshCredentials( - $scope, - $jsonKey['source_credentials'] - ); + $this->sourceCredentials = $jsonKey['source_credentials'] instanceof FetchAuthTokenCredentials + ? $jsonKey['source_credentials'] + : CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); } /** From ba3744218399a370655f4f5ac3d3741bfe91187d Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 12:31:51 -0700 Subject: [PATCH 03/21] fix typos --- .../ImpersonatedServiceAccountCredentials.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 9ba5cdb25..b147bb488 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -21,6 +21,7 @@ use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; use Google\Auth\HttpHandler\HttpHandlerFactory; +use Google\Auth\FetchAuthTokenInterface; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; use GuzzleHttp\Psr7\Request; @@ -59,10 +60,10 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements * @param string|array $jsonKey JSON credential file path or JSON array credentials { * JSON credentials as an associative array. * - * @type string $service_account_impersonation_url The URL to the service account - * @type string|FetchAuthTokenCredentials $source_credentials The source credentials to impersonate - * @type int $lifetime The lifetime of the impersonated credentials - * @type string[] $delegates The delegates to impersonate + * @type string $service_account_impersonation_url The URL to the service account + * @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate + * @type int $lifetime The lifetime of the impersonated credentials + * @type string[] $delegates The delegates to impersonate * } */ public function __construct( @@ -96,7 +97,7 @@ public function __construct( $this->serviceAccountImpersonationUrl ); - $this->sourceCredentials = $jsonKey['source_credentials'] instanceof FetchAuthTokenCredentials + $this->sourceCredentials = $jsonKey['source_credentials'] instanceof FetchAuthTokenInterface ? $jsonKey['source_credentials'] : CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); } From f5a1057de947f4500dbd83b96d52f6308217169d Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 13:37:07 -0700 Subject: [PATCH 04/21] support ID token impersonation --- .../ImpersonatedServiceAccountCredentials.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index b147bb488..f14bce164 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -148,19 +148,16 @@ public function fetchAuthToken(callable $httpHandler = null) { $httpHandler = $httpHandler ?? HttpHandlerFactory::build(); - // We don't support id token endpoint requests as of now for Impersonated Cred - $authToken = $this->sourceCredentials->fetchAuthToken( - $httpHandler, - $this->applyTokenEndpointMetrics([], 'at') + $headers = $this->sourceCredentials->updateMetadata( + ['Content-Type' => 'application/json'], + null, + $httpHandler ); $request = new Request( 'POST', $this->serviceAccountImpersonationUrl, - [ - 'Authorization' => 'Bearer ' . $authToken['access_token'], - 'Content-Type' => 'application/json', - ], + $headers, json_encode([ 'scope' => $this->targetScope, 'delegates' => $this->delegates, From 3b38b221e0ae26dae5ca301e9df2211c9ad632f4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 14:59:48 -0700 Subject: [PATCH 05/21] fix FetchAuthTokenInterface typehint --- src/Credentials/ImpersonatedServiceAccountCredentials.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index f14bce164..bf061c150 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -39,7 +39,7 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements protected $impersonatedServiceAccountName; /** - * @var UserRefreshCredentials + * @var FetchAuthTokenInterface */ protected $sourceCredentials; From 15b6c33c89d4a3b9a5229f025ccfa46a64169b6f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Oct 2024 16:43:16 -0700 Subject: [PATCH 06/21] add support for impersonating id tokens --- src/ApplicationDefaultCredentials.php | 5 +++ .../ImpersonatedServiceAccountCredentials.php | 38 +++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 80437c8c9..1d10ebd36 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -20,6 +20,7 @@ use DomainException; use Google\Auth\Credentials\AppIdentityCredentials; use Google\Auth\Credentials\GCECredentials; +use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; @@ -302,6 +303,10 @@ public static function getIdTokenCredentials( throw new InvalidArgumentException('ID tokens are not supported for end user credentials'); } + if ($jsonKey['type'] == 'impersonated_service_account') { + return new ImpersonatedServiceAccountCredentials($scope, $jsonKey, $targetAudience); + } + if ($jsonKey['type'] != 'service_account') { throw new InvalidArgumentException('invalid value in the type field'); } diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index bf061c150..f86e9e914 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -65,10 +65,12 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements * @type int $lifetime The lifetime of the impersonated credentials * @type string[] $delegates The delegates to impersonate * } + * @param string|null $targetAudience The audience to request an ID token for. */ public function __construct( $scope, - $jsonKey + $jsonKey, + private ?string $targetAudience = null ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -149,24 +151,41 @@ public function fetchAuthToken(callable $httpHandler = null) $httpHandler = $httpHandler ?? HttpHandlerFactory::build(); $headers = $this->sourceCredentials->updateMetadata( - ['Content-Type' => 'application/json'], +[ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + ], null, $httpHandler ); + if ($this->isIdTokenRequest()) { + $body = [ + 'audience' => $this->targetAudience, + 'includeEmail' => true, + ]; + } else { + $body = [ + 'scope' => $this->targetScope, + 'delegates' => $this->delegates, + 'lifetime' => sprintf('%ss', $this->lifetime), + ]; + } + $request = new Request( 'POST', $this->serviceAccountImpersonationUrl, $headers, - json_encode([ - 'scope' => $this->targetScope, - 'delegates' => $this->delegates, - 'lifetime' => sprintf('%ss', $this->lifetime), - ]) + json_encode($body) ); $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); + + if ($this->isIdTokenRequest()) { + return ['id_token' => $body['token']]; + } + return [ 'access_token' => $body['accessToken'], 'expires_at' => strtotime($body['expireTime']), @@ -198,4 +217,9 @@ protected function getCredType(): string { return self::CRED_TYPE; } + + private function isIdTokenRequest(): bool + { + return !is_null($this->targetAudience); + } } From acd55a3cd6c91971c282e393673032177c5947a0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 3 Oct 2024 08:51:08 -0700 Subject: [PATCH 07/21] fix phpstan and Obervability test --- src/ApplicationDefaultCredentials.php | 2 +- .../ImpersonatedServiceAccountCredentials.php | 67 ++++++++++--------- tests/ObservabilityMetricsTest.php | 57 +++++++++++----- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 1d10ebd36..69bbf125e 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -304,7 +304,7 @@ public static function getIdTokenCredentials( } if ($jsonKey['type'] == 'impersonated_service_account') { - return new ImpersonatedServiceAccountCredentials($scope, $jsonKey, $targetAudience); + return new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience); } if ($jsonKey['type'] != 'service_account') { diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index f86e9e914..f9110fecf 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -22,6 +22,7 @@ use Google\Auth\CredentialsLoader; use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\UpdateMetadataInterface; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; use GuzzleHttp\Psr7\Request; @@ -38,15 +39,18 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements */ protected $impersonatedServiceAccountName; - /** - * @var FetchAuthTokenInterface - */ - protected $sourceCredentials; + protected FetchAuthTokenInterface $sourceCredentials; private string $serviceAccountImpersonationUrl; + /** + * @var string[] + */ private array $delegates; + /** + * @var string|string[] + */ private string|array $targetScope; private int $lifetime; @@ -55,17 +59,17 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that * has be created with the --impersonate-service-account flag. * - * @param string|string[] $scope The scope of the access request, expressed either as an - * array or as a space-delimited string. - * @param string|array $jsonKey JSON credential file path or JSON array credentials { - * JSON credentials as an associative array. + * @param string|string[]|null $scope The scope of the access request, expressed either as an + * array or as a space-delimited string. + * @param string|array $jsonKey JSON credential file path or JSON array credentials { + * JSON credentials as an associative array. * * @type string $service_account_impersonation_url The URL to the service account * @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate * @type int $lifetime The lifetime of the impersonated credentials * @type string[] $delegates The delegates to impersonate * } - * @param string|null $targetAudience The audience to request an ID token for. + * @param string|null $targetAudience The audience to request an ID token. */ public function __construct( $scope, @@ -148,48 +152,45 @@ public function getClientName(callable $unusedHttpHandler = null) */ public function fetchAuthToken(callable $httpHandler = null) { - $httpHandler = $httpHandler ?? HttpHandlerFactory::build(); - - $headers = $this->sourceCredentials->updateMetadata( -[ - 'Content-Type' => 'application/json', - 'Cache-Control' => 'no-store', - ], - null, - $httpHandler + $httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + $authToken = $this->sourceCredentials->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics([], 'at') ); - if ($this->isIdTokenRequest()) { - $body = [ + $headers = $this->applyTokenEndpointMetrics([ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), + ], 'at'); + + $body = $this->isIdTokenRequest() + ? [ 'audience' => $this->targetAudience, 'includeEmail' => true, - ]; - } else { - $body = [ + ] : [ 'scope' => $this->targetScope, 'delegates' => $this->delegates, 'lifetime' => sprintf('%ss', $this->lifetime), ]; - } $request = new Request( 'POST', $this->serviceAccountImpersonationUrl, $headers, - json_encode($body) + (string) json_encode($body) ); $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); - if ($this->isIdTokenRequest()) { - return ['id_token' => $body['token']]; - } - - return [ - 'access_token' => $body['accessToken'], - 'expires_at' => strtotime($body['expireTime']), - ]; + return $this->isIdTokenRequest() + ? ['id_token' => $body['token']] + : [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ]; } /** diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php index 450bfa125..c21d4209d 100644 --- a/tests/ObservabilityMetricsTest.php +++ b/tests/ObservabilityMetricsTest.php @@ -125,7 +125,11 @@ public function testImpersonatedServiceAccountCredentials() { $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; $handlerCalled = false; - $handler = $this->getCustomHandler('imp', 'auth-request-type/at', $handlerCalled); + $responseFromIam = json_encode(['accessToken' => '1/abdef1234567890', 'expireTime' => '2024-01-01T00:00:00Z']); + $handler = getHandler([ + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens), + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $responseFromIam), + ]); $impersonatedCred = new ImpersonatedServiceAccountCredentials('exampleScope', $keyFile); $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); @@ -174,25 +178,48 @@ private function assertUpdateMetadata($cred, $handler, $credShortform, &$handler */ private function getCustomHandler($credShortform, $requestTypeHeaderValue, &$handlerCalled) { - $jsonTokens = $this->jsonTokens; return getHandler([ - function ($request, $options) use ( - $jsonTokens, - &$handlerCalled, + $this->getExpectedRequest( + $credShortform, $requestTypeHeaderValue, - $credShortform - ) { - $handlerCalled = true; - // This confirms that token endpoint requests have proper observability metric headers - $this->assertStringContainsString( - sprintf('%s %s cred-type/%s', $this->langAndVersion, $requestTypeHeaderValue, $credShortform), - $request->getHeaderLine(self::$headerKey) - ); - return new Response(200, [], Utils::streamFor($jsonTokens)); - } + $handlerCalled, + $this->jsonTokens + ) ]); } + /** + * @param string $credShortform The short form of the credential type + * used in observability metric header value. + * @param string $requestTypeHeaderValue Expected header value of the form + * 'auth-request-type/<>' + * @param bool $handlerCalled Reference to the handlerCalled flag asserted later + * in the test. + * @param string $jsonTokens The json tokens to be returned in the response. + * @return callable + */ + private function getExpectedRequest( + string $credShortform, + string $requestTypeHeaderValue, + bool &$handlerCalled, + string $jsonTokens + ): callable { + return function ($request, $options) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue, + $credShortform + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/%s', $this->langAndVersion, $requestTypeHeaderValue, $credShortform), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + }; + } + public function tokenRequestType() { return [ From 522f605f5a1216191cacb147bcc6886d651c60f2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 3 Oct 2024 09:41:12 -0700 Subject: [PATCH 08/21] fix phpstan errors, add headers parameter for other credential types --- src/Credentials/ExternalAccountCredentials.php | 4 ++-- src/Credentials/GCECredentials.php | 9 ++++++--- .../ImpersonatedServiceAccountCredentials.php | 5 +++++ src/Credentials/ServiceAccountCredentials.php | 9 +++++++-- src/Credentials/UserRefreshCredentials.php | 4 ++-- src/FetchAuthTokenInterface.php | 3 +++ src/OAuth2.php | 4 ++-- 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 3614d24d0..fb20848e1 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -263,9 +263,9 @@ private function getImpersonatedAccessToken(string $stsToken, callable $httpHand * @type string $token_type (identity pool only) * } */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, array $headers = null) { - $stsToken = $this->auth->fetchAuthToken($httpHandler); + $stsToken = $this->auth->fetchAuthToken($httpHandler, $headers); if (isset($this->serviceAccountImpersonationUrl)) { return $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler); diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 8b7547816..d5fe56857 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -431,7 +431,7 @@ private static function detectResidencyWindows(string $registryProductKey): bool // which might mean that it is a windows instance that is not on GCE return false; } - + return 0 === strpos($productName, self::PRODUCT_NAME); } @@ -442,6 +442,9 @@ private static function detectResidencyWindows(string $registryProductKey): bool * If $httpHandler is not specified a the default HttpHandler is used. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Headers to be inserted + * into the token endpoint request present. + * * * @return array { * A set of auth related metadata, based on the token type. @@ -453,7 +456,7 @@ private static function detectResidencyWindows(string $registryProductKey): bool * } * @throws \Exception */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, array $headers = []) { $httpHandler = $httpHandler ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); @@ -469,7 +472,7 @@ public function fetchAuthToken(callable $httpHandler = null) $response = $this->getFromMetadata( $httpHandler, $this->tokenUri, - $this->applyTokenEndpointMetrics([], $this->targetAudience ? 'it' : 'at') + $this->applyTokenEndpointMetrics($headers, $this->targetAudience ? 'it' : 'at') ); if ($this->targetAudience) { diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index f9110fecf..5cd2d10b5 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -21,6 +21,7 @@ use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; use Google\Auth\HttpHandler\HttpHandlerFactory; +use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\UpdateMetadataInterface; use Google\Auth\IamSignerTrait; @@ -154,6 +155,10 @@ public function fetchAuthToken(callable $httpHandler = null) { $httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + // The FetchAuthTokenInterface technically does not have a "headers" argument, but all of + // the implementations do. Additionally, passing in more parameters than the function has + // defined is allowed in PHP. So we'll just ignore the phpstan error here. + // @phpstan-ignore-next-line $authToken = $this->sourceCredentials->fetchAuthToken( $httpHandler, $this->applyTokenEndpointMetrics([], 'at') diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 5e7915333..cc5b62e09 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -190,6 +190,8 @@ public function useJwtAccessWithScope() /** * @param callable $httpHandler + * @param array $headers [optional] Headers to be inserted + * into the token endpoint request present. * * @return array { * A set of auth related metadata, containing the following @@ -199,7 +201,7 @@ public function useJwtAccessWithScope() * @type string $token_type * } */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, array $headers = []) { if ($this->useSelfSignedJwt()) { $jwtCreds = $this->createJwtAccessCredentials(); @@ -215,7 +217,10 @@ public function fetchAuthToken(callable $httpHandler = null) } $authRequestType = empty($this->auth->getAdditionalClaims()['target_audience']) ? 'at' : 'it'; - return $this->auth->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], $authRequestType)); + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($headers, $authRequestType) + ); } /** diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index d40055562..70abc6429 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -120,12 +120,12 @@ public function __construct( * @type string $id_token * } */ - public function fetchAuthToken(callable $httpHandler = null, array $metricsHeader = []) + public function fetchAuthToken(callable $httpHandler = null, array $headers = []) { // We don't support id token endpoint requests as of now for User Cred return $this->auth->fetchAuthToken( $httpHandler, - $this->applyTokenEndpointMetrics($metricsHeader, 'at') + $this->applyTokenEndpointMetrics($headers, 'at') ); } diff --git a/src/FetchAuthTokenInterface.php b/src/FetchAuthTokenInterface.php index 64659550b..290e41386 100644 --- a/src/FetchAuthTokenInterface.php +++ b/src/FetchAuthTokenInterface.php @@ -26,6 +26,9 @@ interface FetchAuthTokenInterface * Fetches the auth tokens based on the current state. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Headers to be inserted + * into the token endpoint request present. + * * @return array a hash of auth tokens */ public function fetchAuthToken(callable $httpHandler = null); diff --git a/src/OAuth2.php b/src/OAuth2.php index 4019e258a..43fc5252f 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -586,7 +586,7 @@ public function toJwt(array $config = []) * the token endpoint request. * @return RequestInterface the authorization Url. */ - public function generateCredentialsRequest(callable $httpHandler = null, $headers = []) + public function generateCredentialsRequest(callable $httpHandler = null, array $headers = []) { $uri = $this->getTokenCredentialUri(); if (is_null($uri)) { @@ -666,7 +666,7 @@ public function generateCredentialsRequest(callable $httpHandler = null, $header * endpoint request. * @return array the response */ - public function fetchAuthToken(callable $httpHandler = null, $headers = []) + public function fetchAuthToken(callable $httpHandler = null, array $headers = []) { if (is_null($httpHandler)) { $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); From c50541ed47a971a2159f9d3f8833c0f6b105862e Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 3 Oct 2024 09:45:49 -0700 Subject: [PATCH 09/21] fix cs and tests --- src/CredentialSource/AwsNativeSource.php | 2 +- .../ExternalAccountCredentials.php | 2 +- src/Credentials/GCECredentials.php | 2 +- .../ImpersonatedServiceAccountCredentials.php | 5 ++--- src/Middleware/AuthTokenMiddleware.php | 3 ++- src/OAuth2.php | 13 ++++++----- tests/ApplicationDefaultCredentialsTest.php | 6 +++-- tests/Cache/FileSystemCacheItemPoolTest.php | 2 +- tests/CredentialSource/FileSourceTest.php | 1 - .../ExternalAccountCredentialsTest.php | 2 +- tests/Credentials/GCECredentialsTest.php | 2 +- tests/OAuth2Test.php | 22 +++++++++---------- tests/mocks/TestFileCacheItemPool.php | 1 - tests/phpstan-autoload.php | 2 +- 14 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/CredentialSource/AwsNativeSource.php b/src/CredentialSource/AwsNativeSource.php index 6d9244ba2..e99d0ee6f 100644 --- a/src/CredentialSource/AwsNativeSource.php +++ b/src/CredentialSource/AwsNativeSource.php @@ -103,7 +103,7 @@ public function fetchSubjectToken(callable $httpHandler = null): string $headers['x-goog-cloud-target-resource'] = $this->audience; // Format headers as they're expected in the subject token - $formattedHeaders= array_map( + $formattedHeaders = array_map( fn ($k, $v) => ['key' => $k, 'value' => $v], array_keys($headers), $headers, diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index fb20848e1..c8ea3a987 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -263,7 +263,7 @@ private function getImpersonatedAccessToken(string $stsToken, callable $httpHand * @type string $token_type (identity pool only) * } */ - public function fetchAuthToken(callable $httpHandler = null, array $headers = null) + public function fetchAuthToken(callable $httpHandler = null, array $headers = []) { $stsToken = $this->auth->fetchAuthToken($httpHandler, $headers); diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index d5fe56857..8a184be11 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -426,7 +426,7 @@ private static function detectResidencyWindows(string $registryProductKey): bool try { $productName = $shell->regRead($registryProductKey); - } catch(com_exception) { + } catch (com_exception) { // This means that we tried to read a key that doesn't exist on the registry // which might mean that it is a windows instance that is not on GCE return false; diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 5cd2d10b5..44bf6fd17 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -20,10 +20,9 @@ use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; -use Google\Auth\HttpHandler\HttpHandlerFactory; -use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\FetchAuthTokenInterface; -use Google\Auth\UpdateMetadataInterface; +use Google\Auth\HttpHandler\HttpClientCache; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; use GuzzleHttp\Psr7\Request; diff --git a/src/Middleware/AuthTokenMiddleware.php b/src/Middleware/AuthTokenMiddleware.php index 798766efa..3bbda7a23 100644 --- a/src/Middleware/AuthTokenMiddleware.php +++ b/src/Middleware/AuthTokenMiddleware.php @@ -132,7 +132,8 @@ private function addAuthHeaders(RequestInterface $request) ) { $token = $this->fetcher->fetchAuthToken(); $request = $request->withHeader( - 'authorization', 'Bearer ' . ($token['access_token'] ?? $token['id_token'] ?? '') + 'authorization', + 'Bearer ' . ($token['access_token'] ?? $token['id_token'] ?? '') ); } else { $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); diff --git a/src/OAuth2.php b/src/OAuth2.php index 43fc5252f..c876e0fee 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -724,7 +724,7 @@ public function getSubjectTokenFetcher(): ?ExternalAccountCredentialSourceInterf */ public function parseTokenResponse(ResponseInterface $resp) { - $body = (string)$resp->getBody(); + $body = (string) $resp->getBody(); if ($resp->hasHeader('Content-Type') && $resp->getHeaderLine('Content-Type') == 'application/x-www-form-urlencoded' ) { @@ -1009,13 +1009,13 @@ public function setRedirectUri($uri) if (!$this->isAbsoluteUri($uri)) { // "postmessage" is a reserved URI string in Google-land // @see https://developers.google.com/identity/sign-in/web/server-side-flow - if ('postmessage' !== (string)$uri) { + if ('postmessage' !== (string) $uri) { throw new InvalidArgumentException( 'Redirect URI must be absolute' ); } } - $this->redirectUri = (string)$uri; + $this->redirectUri = (string) $uri; } /** @@ -1127,7 +1127,7 @@ public function setGrantType($grantType) 'invalid grant type' ); } - $this->grantType = (string)$grantType; + $this->grantType = (string) $grantType; } } @@ -1460,7 +1460,7 @@ public function setExpiresIn($expiresIn) $this->issuedAt = null; } else { $this->issuedAt = time(); - $this->expiresIn = (int)$expiresIn; + $this->expiresIn = (int) $expiresIn; } } @@ -1768,7 +1768,8 @@ private function getFirebaseJwtKeys($publicKey, $allowedAlgs) throw new \InvalidArgumentException( 'To have multiple allowed algorithms, You must provide an' . ' array of Firebase\JWT\Key objects.' - . ' See https://github.com/firebase/php-jwt for more information.'); + . ' See https://github.com/firebase/php-jwt for more information.' + ); } $allowedAlg = array_pop($allowedAlgs); } else { diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index fa537691f..c1583ed06 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -168,7 +168,8 @@ public function testImpersonatedServiceAccountCredentials() ); $this->assertInstanceOf( 'Google\Auth\Credentials\ImpersonatedServiceAccountCredentials', - $creds); + $creds + ); $this->assertEquals('service_account_name@namespace.iam.gserviceaccount.com', $creds->getClientName()); @@ -179,7 +180,8 @@ public function testImpersonatedServiceAccountCredentials() $sourceCredentials = $sourceCredentialsProperty->getValue($creds); $this->assertInstanceOf( 'Google\Auth\Credentials\UserRefreshCredentials', - $sourceCredentials); + $sourceCredentials + ); } public function testUserRefreshCredentials() diff --git a/tests/Cache/FileSystemCacheItemPoolTest.php b/tests/Cache/FileSystemCacheItemPoolTest.php index a3214587a..86b3e4eb5 100644 --- a/tests/Cache/FileSystemCacheItemPoolTest.php +++ b/tests/Cache/FileSystemCacheItemPoolTest.php @@ -43,7 +43,7 @@ public function tearDown(): void { $files = scandir($this->defaultCacheDirectory); - foreach($files as $fileName) { + foreach ($files as $fileName) { if ($fileName === '.' || $fileName === '..') { continue; } diff --git a/tests/CredentialSource/FileSourceTest.php b/tests/CredentialSource/FileSourceTest.php index e2c79bde7..9cdcbb9cc 100644 --- a/tests/CredentialSource/FileSourceTest.php +++ b/tests/CredentialSource/FileSourceTest.php @@ -45,7 +45,6 @@ public function provideFetchSubjectToken() $file1 = tempnam(sys_get_temp_dir(), 'test1'); file_put_contents($file1, 'abc'); - $file2 = tempnam(sys_get_temp_dir(), 'test2'); file_put_contents($file2, json_encode(['token' => 'def'])); diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php index 09cac05db..4d1f8ae0e 100644 --- a/tests/Credentials/ExternalAccountCredentialsTest.php +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -561,7 +561,7 @@ public function testUrlSourceCacheKey() $expectedKey = 'fakeUrl.scope1...'; $this->assertEquals($expectedKey, $cacheKey); } - + public function testExecutableSourceCacheKey() { $this->baseCreds['credential_source'] = [ diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 7aca40510..f6d9c2266 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -138,7 +138,7 @@ public function testOnWindowsGceWithResidencyWithNoCom() $method = (new ReflectionClass(GCECredentials::class)) ->getMethod('detectResidencyWindows'); - + $method->setAccessible(true); $this->assertFalse($method->invoke(null, 'thisShouldBeFalse')); diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index e00ab647f..14263fce0 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -290,7 +290,7 @@ public function testRedirectUriPostmessageIsAllowed() ]); $this->assertEquals('postmessage', $o->getRedirectUri()); $url = $o->buildFullAuthorizationUri(); - $parts = parse_url((string)$url); + $parts = parse_url((string) $url); parse_str($parts['query'], $query); $this->assertArrayHasKey('redirect_uri', $query); $this->assertEquals('postmessage', $query['redirect_uri']); @@ -726,7 +726,7 @@ public function testGeneratesAuthorizationCodeRequests() $req = $o->generateCredentialsRequest(); $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); $this->assertEquals('POST', $req->getMethod()); - $fields = Query::parse((string)$req->getBody()); + $fields = Query::parse((string) $req->getBody()); $this->assertEquals('authorization_code', $fields['grant_type']); $this->assertEquals('an_auth_code', $fields['code']); } @@ -745,7 +745,7 @@ public function testGeneratesPasswordRequests() $req = $o->generateCredentialsRequest(); $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); $this->assertEquals('POST', $req->getMethod()); - $fields = Query::parse((string)$req->getBody()); + $fields = Query::parse((string) $req->getBody()); $this->assertEquals('password', $fields['grant_type']); $this->assertEquals('a_password', $fields['password']); $this->assertEquals('a_username', $fields['username']); @@ -764,7 +764,7 @@ public function testGeneratesRefreshTokenRequests() $req = $o->generateCredentialsRequest(); $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); $this->assertEquals('POST', $req->getMethod()); - $fields = Query::parse((string)$req->getBody()); + $fields = Query::parse((string) $req->getBody()); $this->assertEquals('refresh_token', $fields['grant_type']); $this->assertEquals('a_refresh_token', $fields['refresh_token']); } @@ -780,7 +780,7 @@ public function testClientSecretAddedIfSetForAuthorizationCodeRequests() $o = new OAuth2($testConfig); $o->setCode('an_auth_code'); $request = $o->generateCredentialsRequest(); - $fields = Query::parse((string)$request->getBody()); + $fields = Query::parse((string) $request->getBody()); $this->assertEquals('a_client_secret', $fields['client_secret']); } @@ -794,7 +794,7 @@ public function testClientSecretAddedIfSetForRefreshTokenRequests() $o = new OAuth2($testConfig); $o->setRefreshToken('a_refresh_token'); $request = $o->generateCredentialsRequest(); - $fields = Query::parse((string)$request->getBody()); + $fields = Query::parse((string) $request->getBody()); $this->assertEquals('a_client_secret', $fields['client_secret']); } @@ -809,7 +809,7 @@ public function testClientSecretAddedIfSetForPasswordRequests() $o->setUsername('a_username'); $o->setPassword('a_password'); $request = $o->generateCredentialsRequest(); - $fields = Query::parse((string)$request->getBody()); + $fields = Query::parse((string) $request->getBody()); $this->assertEquals('a_client_secret', $fields['client_secret']); } @@ -827,7 +827,7 @@ public function testGeneratesAssertionRequests() $req = $o->generateCredentialsRequest(); $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); $this->assertEquals('POST', $req->getMethod()); - $fields = Query::parse((string)$req->getBody()); + $fields = Query::parse((string) $req->getBody()); $this->assertEquals(OAuth2::JWT_URN, $fields['grant_type']); $this->assertArrayHasKey('assertion', $fields); } @@ -846,7 +846,7 @@ public function testGeneratesExtendedRequests() $req = $o->generateCredentialsRequest(); $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $req); $this->assertEquals('POST', $req->getMethod()); - $fields = Query::parse((string)$req->getBody()); + $fields = Query::parse((string) $req->getBody()); $this->assertEquals('my_value', $fields['my_param']); $this->assertEquals('urn:my_test_grant_type', $fields['grant_type']); } @@ -1289,7 +1289,7 @@ public function testStsCredentialsRequestMinimal() $request = $o->generateCredentialsRequest(); $this->assertEquals('POST', $request->getMethod()); $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); - parse_str((string)$request->getBody(), $requestParams); + parse_str((string) $request->getBody(), $requestParams); $this->assertCount(4, $requestParams); $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); $this->assertEquals('xyz', $requestParams['subject_token']); @@ -1314,7 +1314,7 @@ public function testStsCredentialsRequestFull() $request = $o->generateCredentialsRequest(); $this->assertEquals('POST', $request->getMethod()); $this->assertEquals($this->stsMinimal['tokenCredentialUri'], (string) $request->getUri()); - parse_str((string)$request->getBody(), $requestParams); + parse_str((string) $request->getBody(), $requestParams); $this->assertCount(9, $requestParams); $this->assertEquals(OAuth2::STS_URN, $requestParams['grant_type']); diff --git a/tests/mocks/TestFileCacheItemPool.php b/tests/mocks/TestFileCacheItemPool.php index 42c8d5a5c..65fbc8a77 100644 --- a/tests/mocks/TestFileCacheItemPool.php +++ b/tests/mocks/TestFileCacheItemPool.php @@ -37,7 +37,6 @@ final class TestFileCacheItemPool implements CacheItemPoolInterface */ private $deferredItems; - public function __construct(string $cacheDir) { $this->cacheDir = $cacheDir; diff --git a/tests/phpstan-autoload.php b/tests/phpstan-autoload.php index 22a38a245..38e79cbcc 100644 --- a/tests/phpstan-autoload.php +++ b/tests/phpstan-autoload.php @@ -10,7 +10,7 @@ public function __construct(string $command) { //do nothing } - + public function regRead(string $key): string { // do nothing From fa156fa1fd101372f2aee185d179d86d70791b14 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 10:43:37 -0700 Subject: [PATCH 10/21] fix phpstan --- src/Credentials/ExternalAccountCredentials.php | 2 ++ src/Credentials/UserRefreshCredentials.php | 2 +- src/FetchAuthTokenInterface.php | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index c8ea3a987..41854e608 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -252,6 +252,8 @@ private function getImpersonatedAccessToken(string $stsToken, callable $httpHand /** * @param callable $httpHandler + * @param array $headers [optional] Metrics headers to be inserted + * into the token endpoint request present. * * @return array { * A set of auth related metadata, containing the following diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index 70abc6429..748e0fd14 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -105,7 +105,7 @@ public function __construct( /** * @param callable $httpHandler - * @param array $metricsHeader [optional] Metrics headers to be inserted + * @param array $headers [optional] Metrics headers to be inserted * into the token endpoint request present. * This could be passed from ImersonatedServiceAccountCredentials as it uses * UserRefreshCredentials as source credentials. diff --git a/src/FetchAuthTokenInterface.php b/src/FetchAuthTokenInterface.php index 290e41386..b236937cc 100644 --- a/src/FetchAuthTokenInterface.php +++ b/src/FetchAuthTokenInterface.php @@ -26,8 +26,6 @@ interface FetchAuthTokenInterface * Fetches the auth tokens based on the current state. * * @param callable $httpHandler callback which delivers psr7 request - * @param array $headers [optional] Headers to be inserted - * into the token endpoint request present. * * @return array a hash of auth tokens */ From 20f2c562434916ad0c671a5f52432e6e00ad682a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 15:50:17 -0700 Subject: [PATCH 11/21] add tests --- .../ImpersonatedServiceAccountCredentials.php | 27 ++- tests/ApplicationDefaultCredentialsTest.php | 8 + ...ersonatedServiceAccountCredentialsTest.php | 187 ++++++++++++++---- tests/FetchAuthTokenTest.php | 2 + 4 files changed, 179 insertions(+), 45 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 44bf6fd17..fc1b5de26 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,6 +26,8 @@ use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; use GuzzleHttp\Psr7\Request; +use InvalidArgumentException; +use LogicException; class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface { @@ -78,20 +80,28 @@ public function __construct( ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { - throw new \InvalidArgumentException('file does not exist'); + 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'); + throw new LogicException('invalid json for auth config'); } } if (!array_key_exists('service_account_impersonation_url', $jsonKey)) { - throw new \LogicException( + 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'); + throw new LogicException('json key is missing the source_credentials field'); + } + if (!array_key_exists('type', $jsonKey['source_credentials'])) { + throw new InvalidArgumentException('json key source credentials are missing the type field'); + } + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); } $this->targetScope = $scope ?? []; @@ -103,6 +113,15 @@ public function __construct( $this->serviceAccountImpersonationUrl ); + if ( + $targetAudience !== null + && $jsonKey['source_credentials']['type'] === 'service_account' + ) { + // Service account tokens MUST request a scope, and as this token is only used to impersonate + // an ID token, the narrowest scope we can request is `cloud-platform`. + $scope = 'https://www.googleapis.com/auth/cloud-platform'; + } + $this->sourceCredentials = $jsonKey['source_credentials'] instanceof FetchAuthTokenInterface ? $jsonKey['source_credentials'] : CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index c1583ed06..5e0b9c7f2 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -22,6 +22,7 @@ use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\GCECredentials; use Google\Auth\Credentials\ServiceAccountCredentials; +use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\CredentialSource; use Google\Auth\GCECache; @@ -509,6 +510,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound() $this->assertNotNull($creds); } + public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials() + { + putenv('HOME=' . __DIR__ . '/fixtures5'); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com'); + $this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds); + } + public function testGetIdTokenCredentialsWithCacheOptions() { $keyFile = __DIR__ . '/fixtures' . '/private.json'; diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index bbc755acb..35dd75155 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -21,50 +21,60 @@ use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; +use Google\Auth\Middleware\AuthTokenMiddleware; +use Google\Auth\OAuth2; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use LogicException; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; use ReflectionClass; class ImpersonatedServiceAccountCredentialsTest extends TestCase { private const SCOPE = ['scope/1', 'scope/2']; + private const TARGET_AUDIENCE = 'test-target-audience'; - /** - * @dataProvider provideServiceAccountImpersonationJson - */ - public function testGetServiceAccountNameEmail(array $testJson) + public function testGetServiceAccountNameEmail() { - $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson); + $json = $this->userToServiceAccountImpersonationJson; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); $this->assertEquals('test@test-project.iam.gserviceaccount.com', $creds->getClientName()); } - /** - * @dataProvider provideServiceAccountImpersonationJson - */ - public function testGetServiceAccountNameID(array $testJson) + public function testGetServiceAccountNameID() { - $testJson['service_account_impersonation_url'] = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/1234567890987654321:generateAccessToken'; - $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson); + $json = $this->userToServiceAccountImpersonationJson; + $json['service_account_impersonation_url'] = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/1234567890987654321:generateAccessToken'; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); $this->assertEquals('1234567890987654321', $creds->getClientName()); } - /** - * @dataProvider provideServiceAccountImpersonationJson - */ - public function testErrorCredentials(array $testJson) + public function testMissingImpersonationUriThrowsException() { $this->expectException(LogicException::class); $this->expectExceptionMessage('json key is missing the service_account_impersonation_url field'); - new ImpersonatedServiceAccountCredentials(self::SCOPE, $testJson['source_credentials']); + new ImpersonatedServiceAccountCredentials(self::SCOPE, []); + } + + public function testMissingSourceCredentialTypeThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('json key source credentials are missing the type field'); + + new ImpersonatedServiceAccountCredentials(self::SCOPE, [ + 'service_account_impersonation_url' => 'https//google.com', + 'source_credentials' => [] + ]); } /** * @dataProvider provideServiceAccountImpersonationJson */ - public function testSourceCredentialsFromJsonFiles(array $testJson, string $credClass) + public function testSourceCredentialsFromJsonFiles(array $json, string $credClass) { - $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $testJson); + $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $json); $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); $sourceCredentialsProperty->setAccessible(true); @@ -74,38 +84,133 @@ public function testSourceCredentialsFromJsonFiles(array $testJson, string $cred public function provideServiceAccountImpersonationJson() { return [ - [$this->createUserISACTestJson(), UserRefreshCredentials::class], - [$this->createSAISACTestJson(), ServiceAccountCredentials::class], + [$this->userToServiceAccountImpersonationJson, UserRefreshCredentials::class], + [$this->serviceAccountToServiceAccountImpersonationJson, ServiceAccountCredentials::class], ]; } - // Creates a standard JSON auth object for testing. - private function createUserISACTestJson() + /** + * @dataProvider provideServiceAccountImpersonationIdTokenJson + */ + public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, $grantType) { - return [ - 'type' => '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', - ] - ]; + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + $body = (string) $request->getBody(); + parse_str($body, $result); + $this->assertEquals($grantType, $result['grant_type']); + } elseif ($requestCount == 2) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['token' => 'test-id-token'] + }) + ); + }; + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals(2, $requestCount); + $this->assertEquals('test-id-token', $token['id_token']); } - // Creates a standard JSON auth object for testing. - private function createSAISACTestJson() + public function provideServiceAccountImpersonationIdTokenJson() { return [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', - 'source_credentials' => [ - 'client_email' => 'clientemail@clientemail.com', - 'private_key' => 'privatekey123', - 'type' => 'service_account', - ] + [$this->userToServiceAccountImpersonationIdTokenJson, 'refresh_token'], + [$this->serviceAccountToServiceAccountImpersonationIdTokenJson, OAuth2::JWT_URN], ]; } + public function testIdTokenWithAuthTokenMiddleware() + { + $targetAudience = 'test-target-audience'; + $json = $this->userToServiceAccountImpersonationIdTokenJson; + $credentials = new ImpersonatedServiceAccountCredentials(null, $json, $targetAudience); + + // this handler is for the middleware constructor, which will pass it to the ISAC to fetch tokens + $httpHandler = getHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{"access_token":"this.is.an.access.token"}'), + new Response(200, ['Content-Type' => 'application/json'], '{"token":"this.is.an.id.token"}'), + ]); + $middleware = new AuthTokenMiddleware($credentials, $httpHandler); + + // this handler is the actual handler that makes the authenticated request + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + $requestCount++; + $this->assertTrue($request->hasHeader('authorization')); + $this->assertEquals('Bearer this.is.an.id.token', $request->getHeader('authorization')[0] ?? null); + }; + + $middleware($httpHandler)( + new Request('GET', 'https://www.google.com'), + ['auth' => 'google_auth'] + ); + + $this->assertEquals(1, $requestCount); + } + + // User Refresh to Service Account Impersonation JSON Credentials + private array $userToServiceAccountImpersonationJson = [ + 'type' => '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', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private array $serviceAccountToServiceAccountImpersonationJson = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', + 'source_credentials' => [ + 'client_email' => 'clientemail@clientemail.com', + 'private_key' => 'privatekey123', + 'type' => 'service_account', + ] + ]; + + // User Refresh to Service Account Impersonation ID Token JSON Credentials + // NOTE: The only difference is the use of "generateIdToken" instead of + // "generateAccessToken" in the service_account_impersonation_url + private array $userToServiceAccountImpersonationIdTokenJson = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateIdToken', + 'source_credentials' => [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ] + ]; + + // Service Account to Service Account Impersonation ID Token JSON Credentials + // NOTE: The only difference is the use of "generateIdToken" instead of + // "generateAccessToken" in the service_account_impersonation_url + private array $serviceAccountToServiceAccountImpersonationIdTokenJson = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateIdToken', + 'source_credentials' => [ + 'client_email' => 'clientemail@clientemail.com', + 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWgIBAAKBgGhw1WMos5gp2YjV7+fNwXN1tI4/DFXKzwY6TDWsPxkbyfjHgunX\n/sijlnJt3Qs1gBxiwEEjzFFlp39O3/gEbIoYWHR/4sZdqNRFzbhJcTpnUvRlZDBL\nE5h8f5uu4aL4D32WyiELF/vpr533lZCBwWsnN3zIYJxThgRF9i/R7F8tAgMBAAEC\ngYAgUyv4cNSFOA64J18FY82IKtojXKg4tXi1+L01r4YoA03TzgxazBtzhg4+hHpx\nybFJF9dhUe8fElNxN7xiSxw8i5MnfPl+piwbfoENhgrzU0/N14AV/4Pq+WAJQe2M\nxPcI1DPYMEwGjX2PmxqnkC47MyR9agX21YZVc9rpRCgPgQJBALodH492I0ydvEUs\ngT+3DkNqoWx3O3vut7a0+6k+RkM1Yu+hGI8RQDCGwcGhQlOpqJkYGsVegZbxT+AF\nvvIFrIUCQQCPqJbRalHK/QnVj4uovj6JvjTkqFSugfztB4Zm/BPT2eEpjLt+851d\nIJ4brK/HVkQT2zk9eb0YzIBfeQi9WpyJAkB9+BRSf72or+KsV1EsFPScgOG9jn4+\nhfbmvVzQ0ouwFcRfOQRsYVq2/Z7LNiC0i9LHvF7yU+MWjUJo+LqjCWAZAkBHearo\nMIzXgQRGlC/5WgZFhDRO3A2d8aDE0eymCp9W1V24zYNwC4dtEVB5Fncyp5Ihiv40\nvwA9eWoZll+pzo55AkBMMdk95skWeaRv8T0G1duv5VQ7q4us2S2TKbEbC8j83BTP\nNefc3KEugylyAjx24ydxARZXznPi1SFeYVx1KCMZ\n-----END RSA PRIVATE KEY-----\n", + 'type' => 'service_account', + ] + ]; } diff --git a/tests/FetchAuthTokenTest.php b/tests/FetchAuthTokenTest.php index 433dbe851..ebecb15bd 100644 --- a/tests/FetchAuthTokenTest.php +++ b/tests/FetchAuthTokenTest.php @@ -107,7 +107,9 @@ public function provideMakeHttpClient() { return [ ['Google\Auth\Credentials\AppIdentityCredentials'], + ['Google\Auth\Credentials\ExternalAccountCredentials'], ['Google\Auth\Credentials\GCECredentials'], + ['Google\Auth\Credentials\ImpersonatedServiceAccountCredentials'], ['Google\Auth\Credentials\ServiceAccountCredentials'], ['Google\Auth\Credentials\ServiceAccountJwtAccessCredentials'], ['Google\Auth\Credentials\UserRefreshCredentials'], From 244724f2f5c589d7cfd9d625ed0ec0c6a0a8a4a9 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 17:45:03 -0700 Subject: [PATCH 12/21] add impersonate id token metric test --- .../ImpersonatedServiceAccountCredentials.php | 2 +- tests/ObservabilityMetricsTest.php | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index fc1b5de26..13ad26e8a 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -186,7 +186,7 @@ public function fetchAuthToken(callable $httpHandler = null) 'Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), - ], 'at'); + ], $this->isIdTokenRequest() ? 'it' : 'at'); $body = $this->isIdTokenRequest() ? [ diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php index c21d4209d..55b0b595e 100644 --- a/tests/ObservabilityMetricsTest.php +++ b/tests/ObservabilityMetricsTest.php @@ -117,10 +117,6 @@ public function testServiceAccountJwtAccessCredentials() ); } - /** - * ImpersonatedServiceAccountCredentials haven't enabled identity token support hence - * they don't have 'auth-request-type/it' observability metric header check. - */ public function testImpersonatedServiceAccountCredentials() { $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; @@ -135,6 +131,20 @@ public function testImpersonatedServiceAccountCredentials() $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); } + public function testImpersonatedServiceAccountCredentialsWithIdTokens() + { + $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $responseFromIam = json_encode(['token' => '1/abdef1234567890']); + $handler = getHandler([ + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens), + $this->getExpectedRequest('imp', 'auth-request-type/it', $handlerCalled, $responseFromIam), + ]); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials(null, $keyFile, 'test-target-audience'); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + /** * UserRefreshCredentials haven't enabled identity token support hence * they don't have 'auth-request-type/it' observability metric header check. From 66b099b88bdb4e900da8603992e7eafdb399c288 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 18:01:52 -0700 Subject: [PATCH 13/21] fix cs and phpstan --- .../ImpersonatedServiceAccountCredentials.php | 49 ++++++++++--------- tests/ApplicationDefaultCredentialsTest.php | 2 +- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 13ad26e8a..473fda301 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -95,14 +95,25 @@ public function __construct( if (!array_key_exists('source_credentials', $jsonKey)) { throw new LogicException('json key is missing the source_credentials field'); } - if (!array_key_exists('type', $jsonKey['source_credentials'])) { - throw new InvalidArgumentException('json key source credentials are missing the type field'); - } if ($scope && $targetAudience) { throw new InvalidArgumentException( 'Scope and targetAudience cannot both be supplied' ); } + if (is_array($jsonKey['source_credentials'])) { + if (!array_key_exists('type', $jsonKey['source_credentials'])) { + throw new InvalidArgumentException('json key source credentials are missing the type field'); + } + if ( + $targetAudience !== null + && $jsonKey['source_credentials']['type'] === 'service_account' + ) { + // Service account tokens MUST request a scope, and as this token is only used to impersonate + // an ID token, the narrowest scope we can request is `cloud-platform`. + $scope = 'https://www.googleapis.com/auth/cloud-platform'; + } + $jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); + } $this->targetScope = $scope ?? []; $this->lifetime = $jsonKey['lifetime'] ?? 3600; @@ -113,18 +124,7 @@ public function __construct( $this->serviceAccountImpersonationUrl ); - if ( - $targetAudience !== null - && $jsonKey['source_credentials']['type'] === 'service_account' - ) { - // Service account tokens MUST request a scope, and as this token is only used to impersonate - // an ID token, the narrowest scope we can request is `cloud-platform`. - $scope = 'https://www.googleapis.com/auth/cloud-platform'; - } - - $this->sourceCredentials = $jsonKey['source_credentials'] instanceof FetchAuthTokenInterface - ? $jsonKey['source_credentials'] - : CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); + $this->sourceCredentials = $jsonKey['source_credentials']; } /** @@ -188,15 +188,17 @@ public function fetchAuthToken(callable $httpHandler = null) 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), ], $this->isIdTokenRequest() ? 'it' : 'at'); - $body = $this->isIdTokenRequest() - ? [ + $body = match($this->isIdTokenRequest()) { + true => [ 'audience' => $this->targetAudience, 'includeEmail' => true, - ] : [ + ], + false => [ 'scope' => $this->targetScope, 'delegates' => $this->delegates, 'lifetime' => sprintf('%ss', $this->lifetime), - ]; + ] + }; $request = new Request( 'POST', @@ -208,12 +210,13 @@ public function fetchAuthToken(callable $httpHandler = null) $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); - return $this->isIdTokenRequest() - ? ['id_token' => $body['token']] - : [ + return match($this->isIdTokenRequest()) { + true => ['id_token' => $body['token']], + false => [ 'access_token' => $body['accessToken'], 'expires_at' => strtotime($body['expireTime']), - ]; + ] + }; } /** diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 5e0b9c7f2..b83ff7eaf 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -21,8 +21,8 @@ use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\GCECredentials; -use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; +use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\CredentialSource; use Google\Auth\GCECache; From 58b0ed6f07d93a507e9693f1fea13801d5e006e1 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 18:23:12 -0700 Subject: [PATCH 14/21] add tests for arbitrary credentials --- ...ersonatedServiceAccountCredentialsTest.php | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 35dd75155..547f21096 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -22,16 +22,21 @@ use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; use Google\Auth\Middleware\AuthTokenMiddleware; +use Google\Auth\FetchAuthTokenInterface; use Google\Auth\OAuth2; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use LogicException; use PHPUnit\Framework\TestCase; +use Prophecy\PHPUnit\ProphecyTrait; +use Prophecy\Argument; use Psr\Http\Message\RequestInterface; use ReflectionClass; class ImpersonatedServiceAccountCredentialsTest extends TestCase { + use ProphecyTrait; + private const SCOPE = ['scope/1', 'scope/2']; private const TARGET_AUDIENCE = 'test-target-audience'; @@ -94,8 +99,6 @@ public function provideServiceAccountImpersonationJson() */ public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, $grantType) { - $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); - $requestCount = 0; // getting an id token will take two requests $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { @@ -117,14 +120,67 @@ public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, ['Content-Type' => 'application/json'], json_encode(match ($requestCount) { 1 => ['access_token' => 'test-access-token'], - 2 => ['token' => 'test-id-token'] + 2 => ['token' => 'test-impersonated-id-token'] }) ); }; + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); $this->assertEquals(2, $requestCount); - $this->assertEquals('test-id-token', $token['id_token']); + } + + public function testGetIdTokenWithArbitraryCredentials() + { + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('https://some/url', (string) $request->getUri()); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + return new Response(200, [], json_encode(['token' => 'test-impersonated-id-token'])); + }; + + $credentials = $this->prophesize(FetchAuthTokenInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url', + 'source_credentials' => $credentials->reveal(), + ]; + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + } + + public function testGetAccessTokenWithArbitraryCredentials() + { + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('https://some/url', (string) $request->getUri()); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + return new Response( + 200, + [], + json_encode(['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123]) + ); + }; + + $credentials = $this->prophesize(FetchAuthTokenInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url', + 'source_credentials' => $credentials->reveal(), + ]; + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); } public function provideServiceAccountImpersonationIdTokenJson() From 19ae60866454fa562c893049258a3ab3e8454564 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 4 Oct 2024 18:52:47 -0700 Subject: [PATCH 15/21] add test for Impersonating ExternalAccountCreds --- ...ersonatedServiceAccountCredentialsTest.php | 97 ++++++++++++------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 547f21096..c415cb786 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -18,6 +18,7 @@ namespace Google\Auth\Tests\Credentials; +use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; @@ -39,18 +40,19 @@ class ImpersonatedServiceAccountCredentialsTest extends TestCase private const SCOPE = ['scope/1', 'scope/2']; private const TARGET_AUDIENCE = 'test-target-audience'; + private const IMPERSONATION_URL = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateToken'; public function testGetServiceAccountNameEmail() { - $json = $this->userToServiceAccountImpersonationJson; + $json = self::USER_TO_SERVICE_ACCOUNT_JSON; $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); $this->assertEquals('test@test-project.iam.gserviceaccount.com', $creds->getClientName()); } public function testGetServiceAccountNameID() { - $json = $this->userToServiceAccountImpersonationJson; - $json['service_account_impersonation_url'] = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/1234567890987654321:generateAccessToken'; + $json = self::USER_TO_SERVICE_ACCOUNT_JSON; + $json['service_account_impersonation_url'] = 'https://some/arbitrary/url/1234567890987654321:generateAccessToken'; $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); $this->assertEquals('1234567890987654321', $creds->getClientName()); } @@ -89,8 +91,8 @@ public function testSourceCredentialsFromJsonFiles(array $json, string $credClas public function provideServiceAccountImpersonationJson() { return [ - [$this->userToServiceAccountImpersonationJson, UserRefreshCredentials::class], - [$this->serviceAccountToServiceAccountImpersonationJson, ServiceAccountCredentials::class], + [self::USER_TO_SERVICE_ACCOUNT_JSON, UserRefreshCredentials::class], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, ServiceAccountCredentials::class], ]; } @@ -131,6 +133,42 @@ public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, $this->assertEquals(2, $requestCount); } + public function testGetIdTokenWithExternalAccountToServiceAccountImpersonationCredentials() + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals( + $json['source_credentials']['credential_source']['url'], + (string) $request->getUri() + ); + } elseif ($requestCount == 2) { + $this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri()); + } elseif ($requestCount == 3) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['access_token' => 'test-access-token'], + 3 => ['token' => 'test-impersonated-id-token'] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals(3, $requestCount); + } + public function testGetIdTokenWithArbitraryCredentials() { $httpHandler = function (RequestInterface $request) { @@ -186,16 +224,15 @@ public function testGetAccessTokenWithArbitraryCredentials() public function provideServiceAccountImpersonationIdTokenJson() { return [ - [$this->userToServiceAccountImpersonationIdTokenJson, 'refresh_token'], - [$this->serviceAccountToServiceAccountImpersonationIdTokenJson, OAuth2::JWT_URN], + [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], ]; } public function testIdTokenWithAuthTokenMiddleware() { $targetAudience = 'test-target-audience'; - $json = $this->userToServiceAccountImpersonationIdTokenJson; - $credentials = new ImpersonatedServiceAccountCredentials(null, $json, $targetAudience); + $credentials = new ImpersonatedServiceAccountCredentials(null, self::USER_TO_SERVICE_ACCOUNT_JSON, $targetAudience); // this handler is for the middleware constructor, which will pass it to the ISAC to fetch tokens $httpHandler = getHandler([ @@ -221,9 +258,9 @@ public function testIdTokenWithAuthTokenMiddleware() } // User Refresh to Service Account Impersonation JSON Credentials - private array $userToServiceAccountImpersonationJson = [ + private const USER_TO_SERVICE_ACCOUNT_JSON = [ 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, 'source_credentials' => [ 'client_id' => 'client123', 'client_secret' => 'clientSecret123', @@ -233,40 +270,28 @@ public function testIdTokenWithAuthTokenMiddleware() ]; // Service Account to Service Account Impersonation JSON Credentials - private array $serviceAccountToServiceAccountImpersonationJson = [ + private const SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, 'source_credentials' => [ 'client_email' => 'clientemail@clientemail.com', - 'private_key' => 'privatekey123', + 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWgIBAAKBgGhw1WMos5gp2YjV7+fNwXN1tI4/DFXKzwY6TDWsPxkbyfjHgunX\n/sijlnJt3Qs1gBxiwEEjzFFlp39O3/gEbIoYWHR/4sZdqNRFzbhJcTpnUvRlZDBL\nE5h8f5uu4aL4D32WyiELF/vpr533lZCBwWsnN3zIYJxThgRF9i/R7F8tAgMBAAEC\ngYAgUyv4cNSFOA64J18FY82IKtojXKg4tXi1+L01r4YoA03TzgxazBtzhg4+hHpx\nybFJF9dhUe8fElNxN7xiSxw8i5MnfPl+piwbfoENhgrzU0/N14AV/4Pq+WAJQe2M\nxPcI1DPYMEwGjX2PmxqnkC47MyR9agX21YZVc9rpRCgPgQJBALodH492I0ydvEUs\ngT+3DkNqoWx3O3vut7a0+6k+RkM1Yu+hGI8RQDCGwcGhQlOpqJkYGsVegZbxT+AF\nvvIFrIUCQQCPqJbRalHK/QnVj4uovj6JvjTkqFSugfztB4Zm/BPT2eEpjLt+851d\nIJ4brK/HVkQT2zk9eb0YzIBfeQi9WpyJAkB9+BRSf72or+KsV1EsFPScgOG9jn4+\nhfbmvVzQ0ouwFcRfOQRsYVq2/Z7LNiC0i9LHvF7yU+MWjUJo+LqjCWAZAkBHearo\nMIzXgQRGlC/5WgZFhDRO3A2d8aDE0eymCp9W1V24zYNwC4dtEVB5Fncyp5Ihiv40\nvwA9eWoZll+pzo55AkBMMdk95skWeaRv8T0G1duv5VQ7q4us2S2TKbEbC8j83BTP\nNefc3KEugylyAjx24ydxARZXznPi1SFeYVx1KCMZ\n-----END RSA PRIVATE KEY-----\n", 'type' => 'service_account', ] ]; - // User Refresh to Service Account Impersonation ID Token JSON Credentials - // NOTE: The only difference is the use of "generateIdToken" instead of - // "generateAccessToken" in the service_account_impersonation_url - private array $userToServiceAccountImpersonationIdTokenJson = [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateIdToken', - 'source_credentials' => [ - 'client_id' => 'client123', - 'client_secret' => 'clientSecret123', - 'refresh_token' => 'refreshToken123', - 'type' => 'authorized_user', - ] - ]; - - // Service Account to Service Account Impersonation ID Token JSON Credentials - // NOTE: The only difference is the use of "generateIdToken" instead of - // "generateAccessToken" in the service_account_impersonation_url - private array $serviceAccountToServiceAccountImpersonationIdTokenJson = [ + // Service Account to Service Account Impersonation JSON Credentials + private const EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateIdToken', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, 'source_credentials' => [ - 'client_email' => 'clientemail@clientemail.com', - 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWgIBAAKBgGhw1WMos5gp2YjV7+fNwXN1tI4/DFXKzwY6TDWsPxkbyfjHgunX\n/sijlnJt3Qs1gBxiwEEjzFFlp39O3/gEbIoYWHR/4sZdqNRFzbhJcTpnUvRlZDBL\nE5h8f5uu4aL4D32WyiELF/vpr533lZCBwWsnN3zIYJxThgRF9i/R7F8tAgMBAAEC\ngYAgUyv4cNSFOA64J18FY82IKtojXKg4tXi1+L01r4YoA03TzgxazBtzhg4+hHpx\nybFJF9dhUe8fElNxN7xiSxw8i5MnfPl+piwbfoENhgrzU0/N14AV/4Pq+WAJQe2M\nxPcI1DPYMEwGjX2PmxqnkC47MyR9agX21YZVc9rpRCgPgQJBALodH492I0ydvEUs\ngT+3DkNqoWx3O3vut7a0+6k+RkM1Yu+hGI8RQDCGwcGhQlOpqJkYGsVegZbxT+AF\nvvIFrIUCQQCPqJbRalHK/QnVj4uovj6JvjTkqFSugfztB4Zm/BPT2eEpjLt+851d\nIJ4brK/HVkQT2zk9eb0YzIBfeQi9WpyJAkB9+BRSf72or+KsV1EsFPScgOG9jn4+\nhfbmvVzQ0ouwFcRfOQRsYVq2/Z7LNiC0i9LHvF7yU+MWjUJo+LqjCWAZAkBHearo\nMIzXgQRGlC/5WgZFhDRO3A2d8aDE0eymCp9W1V24zYNwC4dtEVB5Fncyp5Ihiv40\nvwA9eWoZll+pzo55AkBMMdk95skWeaRv8T0G1duv5VQ7q4us2S2TKbEbC8j83BTP\nNefc3KEugylyAjx24ydxARZXznPi1SFeYVx1KCMZ\n-----END RSA PRIVATE KEY-----\n", - 'type' => 'service_account', + 'type' => 'external_account', + 'audience' => 'some_audience', + 'subject_token_type' => 'access_token', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => [ + 'url' => 'https://some.url/token' + ] ] ]; } From a70d2369147d232b2c720f293e57d1be4bd5faf9 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Sat, 5 Oct 2024 10:44:09 -0700 Subject: [PATCH 16/21] ensure complete test coverage, fix cs --- .../ImpersonatedServiceAccountCredentials.php | 4 +- ...ersonatedServiceAccountCredentialsTest.php | 124 +++++++++++++++--- 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 473fda301..d94e0ddad 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -188,7 +188,7 @@ public function fetchAuthToken(callable $httpHandler = null) 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), ], $this->isIdTokenRequest() ? 'it' : 'at'); - $body = match($this->isIdTokenRequest()) { + $body = match ($this->isIdTokenRequest()) { true => [ 'audience' => $this->targetAudience, 'includeEmail' => true, @@ -210,7 +210,7 @@ public function fetchAuthToken(callable $httpHandler = null) $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); - return match($this->isIdTokenRequest()) { + return match ($this->isIdTokenRequest()) { true => ['id_token' => $body['token']], false => [ 'access_token' => $body['accessToken'], diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index c415cb786..2cf85bb1e 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -22,15 +22,15 @@ use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; -use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\OAuth2; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use LogicException; use PHPUnit\Framework\TestCase; -use Prophecy\PHPUnit\ProphecyTrait; use Prophecy\Argument; +use Prophecy\PHPUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use ReflectionClass; @@ -77,9 +77,9 @@ public function testMissingSourceCredentialTypeThrowsException() } /** - * @dataProvider provideServiceAccountImpersonationJson + * @dataProvider provideSourceCredentialsClass */ - public function testSourceCredentialsFromJsonFiles(array $json, string $credClass) + public function testSourceCredentialsClass(array $json, string $credClass) { $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $json); @@ -88,18 +88,21 @@ public function testSourceCredentialsFromJsonFiles(array $json, string $credClas $this->assertInstanceOf($credClass, $sourceCredentialsProperty->getValue($creds)); } - public function provideServiceAccountImpersonationJson() + public function provideSourceCredentialsClass() { return [ [self::USER_TO_SERVICE_ACCOUNT_JSON, UserRefreshCredentials::class], [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, ServiceAccountCredentials::class], + [self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, ExternalAccountCredentials::class], ]; } /** - * @dataProvider provideServiceAccountImpersonationIdTokenJson + * Test access token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson */ - public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, $grantType) + public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) { $requestCount = 0; // getting an id token will take two requests @@ -107,8 +110,45 @@ public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, if (++$requestCount == 1) { // the call to swap the refresh token for an access token $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); - $body = (string) $request->getBody(); - parse_str($body, $result); + parse_str((string) $request->getBody(), $result); + $this->assertEquals($grantType, $result['grant_type']); + } elseif ($requestCount == 2) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::SCOPE, json_decode($request->getBody(), true)['scope'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); + $this->assertEquals(2, $requestCount); + } + + /** + * Test ID token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) + { + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + parse_str((string) $request->getBody(), $result); $this->assertEquals($grantType, $result['grant_type']); } elseif ($requestCount == 2) { // the call to swap the access token for an id token @@ -133,7 +173,57 @@ public function testGetIdTokenWithServiceAccountImpersonationCredentials($json, $this->assertEquals(2, $requestCount); } - public function testGetIdTokenWithExternalAccountToServiceAccountImpersonationCredentials() + public function provideAuthTokenJson() + { + return [ + [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], + ]; + } + + /** + * Test access token impersonation for Exernal Account Credentials. + */ + public function testGetAccessTokenWithExternalAccountCredentials() + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals( + $json['source_credentials']['credential_source']['url'], + (string) $request->getUri() + ); + } elseif ($requestCount == 2) { + $this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri()); + } elseif ($requestCount == 3) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::SCOPE, json_decode($request->getBody(), true)['scope'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['access_token' => 'test-access-token'], + 3 => ['accessToken' => 'test-impersonated-access-token', 'expireTime' => 123] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-access-token', $token['access_token']); + $this->assertEquals(3, $requestCount); + } + + /** + * Test ID token impersonation for Exernal Account Credentials. + */ + public function testGetIdTokenWithExternalAccountCredentials() { $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; $httpHandler = function (RequestInterface $request) use (&$requestCount, $json) { @@ -169,6 +259,9 @@ public function testGetIdTokenWithExternalAccountToServiceAccountImpersonationCr $this->assertEquals(3, $requestCount); } + /** + * Test ID token impersonation for an arbitrary credential fetcher. + */ public function testGetIdTokenWithArbitraryCredentials() { $httpHandler = function (RequestInterface $request) { @@ -193,6 +286,9 @@ public function testGetIdTokenWithArbitraryCredentials() $this->assertEquals('test-impersonated-id-token', $token['id_token']); } + /** + * Test access token impersonation for an arbitrary credential fetcher. + */ public function testGetAccessTokenWithArbitraryCredentials() { $httpHandler = function (RequestInterface $request) { @@ -221,14 +317,6 @@ public function testGetAccessTokenWithArbitraryCredentials() $this->assertEquals('test-impersonated-access-token', $token['access_token']); } - public function provideServiceAccountImpersonationIdTokenJson() - { - return [ - [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], - [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], - ]; - } - public function testIdTokenWithAuthTokenMiddleware() { $targetAudience = 'test-target-audience'; From c63443cdb3e0a7fe4812ab267bbb9a3f673965c3 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 7 Oct 2024 10:33:27 -0700 Subject: [PATCH 17/21] change scope to auth/iam, use constant --- src/Credentials/ImpersonatedServiceAccountCredentials.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index d94e0ddad..e1c8428b8 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -35,6 +35,7 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements use IamSignerTrait; private const CRED_TYPE = 'imp'; + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; /** * @var string @@ -109,8 +110,8 @@ public function __construct( && $jsonKey['source_credentials']['type'] === 'service_account' ) { // Service account tokens MUST request a scope, and as this token is only used to impersonate - // an ID token, the narrowest scope we can request is `cloud-platform`. - $scope = 'https://www.googleapis.com/auth/cloud-platform'; + // an ID token, the narrowest scope we can request is `iam`. + $scope = self::IAM_SCOPE; } $jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); } From df63099b27a55e9d030a969d33f50f493bc05ca2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 5 Nov 2024 12:12:20 -0800 Subject: [PATCH 18/21] fix test --- src/Credentials/GCECredentials.php | 1 - .../ImpersonatedServiceAccountCredentials.php | 14 ++++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index b49d76797..ab6753bd8 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -445,7 +445,6 @@ private static function detectResidencyWindows(string $registryProductKey): bool * @param array $headers [optional] Headers to be inserted * into the token endpoint request present. * - * * @return array { * A set of auth related metadata, based on the token type. * diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 590cfd15b..264e90020 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -58,12 +58,6 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements private int $lifetime; - /** - * Whether this is an ID token request or an access token request. Used when - * building the metric header. - */ - private bool $isIdTokenRequest = false; - /** * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that * has be created with the --impersonate-service-account flag. @@ -193,9 +187,9 @@ public function fetchAuthToken(?callable $httpHandler = null) 'Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), - ], $this->isIdTokenRequest ? 'it' : 'at'); + ], $this->isIdTokenRequest() ? 'it' : 'at'); - $body = match ($this->isIdTokenRequest) { + $body = match ($this->isIdTokenRequest()) { true => [ 'audience' => $this->targetAudience, 'includeEmail' => true, @@ -217,7 +211,7 @@ public function fetchAuthToken(?callable $httpHandler = null) $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); - return match ($this->isIdTokenRequest) { + return match ($this->isIdTokenRequest()) { true => ['id_token' => $body['token']], false => [ 'access_token' => $body['accessToken'], @@ -252,7 +246,7 @@ protected function getCredType(): string return self::CRED_TYPE; } - private function isIdTokenRequest: bool + private function isIdTokenRequest(): bool { return !is_null($this->targetAudience); } From 54b2284d78ec0c2a5f5402323978d5bd692dcfd4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 5 Nov 2024 13:13:16 -0800 Subject: [PATCH 19/21] fix cs --- src/ApplicationDefaultCredentials.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index cc99fee4d..38982a9f2 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -300,7 +300,7 @@ public static function getIdTokenCredentials( throw new \InvalidArgumentException('json key is missing the type field'); } - $creds = match($jsonKey['type']) { + $creds = match ($jsonKey['type']) { 'authorized_user' => new UserRefreshCredentials(null, $jsonKey, $targetAudience), 'impersonated_service_account' => new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience), 'service_account' => new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience), From dca23f034ae08b90c948835c73ede049f4c928e6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 5 Nov 2024 13:48:43 -0800 Subject: [PATCH 20/21] fix case --- tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 2cf85bb1e..fe950156f 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -30,7 +30,7 @@ use LogicException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PHPUnit\ProphecyTrait; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use ReflectionClass; From 76f48e64352244ede27ce5e6f396aae9729333c2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 13 Nov 2024 11:53:02 -0800 Subject: [PATCH 21/21] Update src/FetchAuthTokenInterface.php --- src/FetchAuthTokenInterface.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/FetchAuthTokenInterface.php b/src/FetchAuthTokenInterface.php index f964328ec..fbbd8b0c9 100644 --- a/src/FetchAuthTokenInterface.php +++ b/src/FetchAuthTokenInterface.php @@ -26,7 +26,6 @@ interface FetchAuthTokenInterface * Fetches the auth tokens based on the current state. * * @param callable|null $httpHandler callback which delivers psr7 request - * * @return array a hash of auth tokens */ public function fetchAuthToken(?callable $httpHandler = null);