From 4d34f149aa61a0e661f57e1220cdb09108b1379b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 5 Nov 2024 13:40:36 -0800 Subject: [PATCH] feat: add support for Impersonated Service Account Credentials (#580) --- src/ApplicationDefaultCredentials.php | 14 +- .../ExternalAccountCredentials.php | 6 +- src/Credentials/GCECredentials.php | 6 +- .../ImpersonatedServiceAccountCredentials.php | 137 ++++++- src/Credentials/ServiceAccountCredentials.php | 24 +- src/Credentials/UserRefreshCredentials.php | 6 +- src/CredentialsLoader.php | 1 + src/FetchAuthTokenInterface.php | 1 + src/OAuth2.php | 4 +- tests/ApplicationDefaultCredentialsTest.php | 7 + ...ersonatedServiceAccountCredentialsTest.php | 377 ++++++++++++++++-- tests/FetchAuthTokenTest.php | 2 + tests/ObservabilityMetricsTest.php | 79 +++- 13 files changed, 567 insertions(+), 97 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 1593721f0..38982a9f2 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\Credentials\UserRefreshCredentials; use Google\Auth\HttpHandler\HttpClientCache; @@ -299,13 +300,12 @@ public static function getIdTokenCredentials( throw new \InvalidArgumentException('json key is missing the type field'); } - if ($jsonKey['type'] == 'authorized_user') { - $creds = new UserRefreshCredentials(null, $jsonKey, $targetAudience); - } elseif ($jsonKey['type'] == 'service_account') { - $creds = new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience); - } else { - throw new InvalidArgumentException('invalid value in the type field'); - } + $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), + default => throw new InvalidArgumentException('invalid value in the type field') + }; } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { $creds = new GCECredentials(null, null, $targetAudience); $creds->setIsOnGce(true); // save the credentials a trip to the metadata server diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 21ed1b602..478063be1 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -252,6 +252,8 @@ private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHan /** * @param callable|null $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 @@ -263,9 +265,9 @@ private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHan * @type string $token_type (identity pool only) * } */ - public function fetchAuthToken(?callable $httpHandler = null) + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) { - $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 430e6e6a7..ab6753bd8 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -442,6 +442,8 @@ private static function detectResidencyWindows(string $registryProductKey): bool * If $httpHandler is not specified a the default HttpHandler is used. * * @param callable|null $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 +455,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 +471,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 c34a1539b..264e90020 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -18,65 +18,114 @@ namespace Google\Auth\Credentials; +use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; +use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\HttpHandler\HttpClientCache; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; +use GuzzleHttp\Psr7\Request; +use InvalidArgumentException; +use LogicException; class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface { + use CacheTrait; use IamSignerTrait; private const CRED_TYPE = 'imp'; + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; /** * @var string */ protected $impersonatedServiceAccountName; + protected FetchAuthTokenInterface $sourceCredentials; + + private string $serviceAccountImpersonationUrl; + + /** + * @var string[] + */ + private array $delegates; + /** - * @var UserRefreshCredentials + * @var string|string[] */ - protected $sourceCredentials; + 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. - * @param string|array $jsonKey JSON credential file path or 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. */ public function __construct( $scope, - $jsonKey + $jsonKey, + private ?string $targetAudience = null ) { 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 ($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 `iam`. + $scope = self::IAM_SCOPE; + } + $jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); } + $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( - $scope, - $jsonKey['source_credentials'] - ); + $this->sourceCredentials = $jsonKey['source_credentials']; } /** @@ -123,11 +172,52 @@ public function getClientName(?callable $unusedHttpHandler = null) */ public function fetchAuthToken(?callable $httpHandler = null) { - // We don't support id token endpoint requests as of now for Impersonated Cred - return $this->sourceCredentials->fetchAuthToken( + $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') ); + + $headers = $this->applyTokenEndpointMetrics([ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), + ], $this->isIdTokenRequest() ? 'it' : 'at'); + + $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', + $this->serviceAccountImpersonationUrl, + $headers, + (string) json_encode($body) + ); + + $response = $httpHandler($request); + $body = json_decode((string) $response->getBody(), true); + + return match ($this->isIdTokenRequest()) { + true => ['id_token' => $body['token']], + false => [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ] + }; } /** @@ -138,7 +228,9 @@ public function fetchAuthToken(?callable $httpHandler = null) */ public function getCacheKey() { - return $this->sourceCredentials->getCacheKey(); + return $this->getFullCacheKey( + $this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey() + ); } /** @@ -153,4 +245,9 @@ protected function getCredType(): string { return self::CRED_TYPE; } + + private function isIdTokenRequest(): bool + { + return !is_null($this->targetAudience); + } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index c13b22921..3d23f71af 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -114,6 +114,12 @@ class ServiceAccountCredentials extends CredentialsLoader implements */ private string $universeDomain; + /** + * Whether this is an ID token request or an access token request. Used when + * building the metric header. + */ + private bool $isIdTokenRequest = false; + /** * Create a new ServiceAccountCredentials. * @@ -161,6 +167,7 @@ public function __construct( $additionalClaims = []; if ($targetAudience) { $additionalClaims = ['target_audience' => $targetAudience]; + $this->isIdTokenRequest = true; } $this->auth = new OAuth2([ 'audience' => self::TOKEN_CREDENTIAL_URI, @@ -194,6 +201,8 @@ public function useJwtAccessWithScope() /** * @param callable|null $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 @@ -203,7 +212,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(); @@ -218,7 +227,7 @@ public function fetchAuthToken(?callable $httpHandler = null) return $accessToken; } - if ($this->isIdTokenRequest() && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + if ($this->isIdTokenRequest && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { $now = time(); $jwt = Jwt::encode( [ @@ -237,13 +246,13 @@ public function fetchAuthToken(?callable $httpHandler = null) $this->auth->getIssuer(), $this->auth->getAdditionalClaims()['target_audience'], $jwt, - $this->applyTokenEndpointMetrics([], 'it') + $this->applyTokenEndpointMetrics($headers, 'it') ); return ['id_token' => $idToken]; } return $this->auth->fetchAuthToken( $httpHandler, - $this->applyTokenEndpointMetrics([], $this->isIdTokenRequest() ? 'it' : 'at') + $this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at') ); } @@ -429,7 +438,7 @@ private function useSelfSignedJwt() } // Do not use self-signed JWT for ID tokens - if ($this->isIdTokenRequest()) { + if ($this->isIdTokenRequest) { return false; } @@ -445,9 +454,4 @@ private function useSelfSignedJwt() return is_null($this->auth->getScope()); } - - private function isIdTokenRequest(): bool - { - return !empty($this->auth->getAdditionalClaims()['target_audience']); - } } diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index 051634f79..1127ec6be 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -126,7 +126,7 @@ public function __construct( /** * @param callable|null $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. @@ -141,11 +141,11 @@ public function __construct( * @type string $id_token * } */ - public function fetchAuthToken(?callable $httpHandler = null, array $metricsHeader = []) + public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) { return $this->auth->fetchAuthToken( $httpHandler, - $this->applyTokenEndpointMetrics($metricsHeader, $this->isIdTokenRequest ? 'it' : 'at') + $this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at') ); } diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 9e612caca..6e21a27c0 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/src/FetchAuthTokenInterface.php b/src/FetchAuthTokenInterface.php index fbbd8b0c9..f964328ec 100644 --- a/src/FetchAuthTokenInterface.php +++ b/src/FetchAuthTokenInterface.php @@ -26,6 +26,7 @@ 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); diff --git a/src/OAuth2.php b/src/OAuth2.php index 42b847e3d..c60b8827f 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)) { @@ -669,7 +669,7 @@ public function generateCredentialsRequest(?callable $httpHandler = null, $heade * 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()); diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 1e8a378ef..230d2a47f 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -496,6 +496,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound() ); } + 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 9eeb418cb..2cf85bb1e 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -18,55 +18,368 @@ 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; +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\Argument; +use Prophecy\PHPUnit\ProphecyTrait; +use Psr\Http\Message\RequestInterface; +use ReflectionClass; class ImpersonatedServiceAccountCredentialsTest extends TestCase { - // Creates a standard JSON auth object for testing. - private function createISACTestJson() + use ProphecyTrait; + + 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 = 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 = 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()); + } + + public function testMissingImpersonationUriThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('json key is missing the service_account_impersonation_url field'); + + 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 provideSourceCredentialsClass + */ + public function testSourceCredentialsClass(array $json, string $credClass) + { + $creds = new ImpersonatedServiceAccountCredentials(['scope/1', 'scope/2'], $json); + + $sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials'); + $sourceCredentialsProperty->setAccessible(true); + $this->assertInstanceOf($credClass, $sourceCredentialsProperty->getValue($creds)); + } + + public function provideSourceCredentialsClass() { 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', - ] + [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], ]; } - public function testGetServiceAccountNameEmail() + /** + * Test access token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) { - $testJson = $this->createISACTestJson(); - $scope = ['scope/1', 'scope/2']; - $sa = new ImpersonatedServiceAccountCredentials( - $scope, - $testJson - ); - $this->assertEquals('test@test-project.iam.gserviceaccount.com', $sa->getClientName()); + $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 + $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); } - public function testGetServiceAccountNameID() + /** + * Test ID token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) { - $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()); + $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 + $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-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); } - public function testErrorCredentials() + public function provideAuthTokenJson() { - $testJson = $this->createISACTestJson(); - $scope = ['scope/1', 'scope/2']; - $this->expectException(LogicException::class); - new ImpersonatedServiceAccountCredentials($scope, $testJson['source_credentials']); + 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) { + 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); + } + + /** + * Test ID token impersonation for an arbitrary credential fetcher. + */ + 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']); + } + + /** + * Test access token impersonation for an arbitrary credential fetcher. + */ + 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 testIdTokenWithAuthTokenMiddleware() + { + $targetAudience = 'test-target-audience'; + $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([ + 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 const USER_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + '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', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + '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' + ] + ] + ]; } 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'], diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php index 002abc15e..c71e8746f 100644 --- a/tests/ObservabilityMetricsTest.php +++ b/tests/ObservabilityMetricsTest.php @@ -117,20 +117,38 @@ 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'; $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); } + 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. + */ public function testUserRefreshCredentials() { $keyFile = __DIR__ . '/fixtures2/gcloud.json'; @@ -180,25 +198,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 [