diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index a64af46a9..78b266e34 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -153,6 +153,7 @@ public static function getMiddleware( * @param string|null $universeDomain Specifies a universe domain to use for the * calling client library. * @param null|false|LoggerInterface $logger A PSR3 compliant LoggerInterface. + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. * * @return FetchAuthTokenInterface * @throws DomainException if no implementation can be obtained. @@ -166,6 +167,7 @@ public static function getCredentials( $defaultScope = null, ?string $universeDomain = null, null|false|LoggerInterface $logger = null, + bool $enableTrustBoundary = false ) { $creds = null; $jsonKey = CredentialsLoader::fromEnv() @@ -196,12 +198,18 @@ public static function getCredentials( $creds = CredentialsLoader::makeCredentials( $scope, $jsonKey, - $defaultScope + $defaultScope, + $enableTrustBoundary ); } elseif (AppIdentityCredentials::onAppEngine() && !GCECredentials::onAppEngineFlexible()) { $creds = new AppIdentityCredentials($anyScope); } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { - $creds = new GCECredentials(null, $anyScope, null, $quotaProject, null, $universeDomain); + $creds = new GCECredentials( + scope: $anyScope, + quotaProject: $quotaProject, + universeDomain: $universeDomain, + enableTrustBoundary: $enableTrustBoundary, + ); $creds->setIsOnGce(true); // save the credentials a trip to the metadata server } @@ -286,7 +294,7 @@ public static function getIdTokenCredentials( $targetAudience, ?callable $httpHandler = null, ?array $cacheConfig = null, - ?CacheItemPoolInterface $cache = null + ?CacheItemPoolInterface $cache = null, ) { $creds = null; $jsonKey = CredentialsLoader::fromEnv() @@ -308,12 +316,20 @@ public static function getIdTokenCredentials( $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), + 'impersonated_service_account' => new ImpersonatedServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + targetAudience: $targetAudience, + ), + 'service_account' => new ServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + targetAudience: $targetAudience, + ), default => throw new InvalidArgumentException('invalid value in the type field') }; } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { - $creds = new GCECredentials(null, null, $targetAudience); + $creds = new GCECredentials(targetAudience: $targetAudience); $creds->setIsOnGce(true); // save the credentials a trip to the metadata server } diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index ab6753bd8..693c66f05 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -27,6 +27,7 @@ use Google\Auth\IamSignerTrait; use Google\Auth\ProjectIdProviderInterface; use Google\Auth\SignBlobInterface; +use Google\Auth\TrustBoundaryTrait; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; @@ -64,6 +65,7 @@ class GCECredentials extends CredentialsLoader implements GetQuotaProjectInterface { use IamSignerTrait; + use TrustBoundaryTrait; // phpcs:disable const cacheKey = 'GOOGLE_AUTH_PHP_GCE'; @@ -209,6 +211,7 @@ class GCECredentials extends CredentialsLoader implements * account identity name to use instead of "default". * @param string|null $universeDomain [optional] Specify a universe domain to use * instead of fetching one from the metadata server. + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. */ public function __construct( ?Iam $iam = null, @@ -216,7 +219,8 @@ public function __construct( $targetAudience = null, $quotaProject = null, $serviceAccountIdentity = null, - ?string $universeDomain = null + ?string $universeDomain = null, + bool $enableTrustBoundary = false ) { $this->iam = $iam; @@ -245,6 +249,7 @@ public function __construct( $this->quotaProject = $quotaProject; $this->serviceAccountIdentity = $serviceAccountIdentity; $this->universeDomain = $universeDomain; + $this->enableTrustBoundary = $enableTrustBoundary; } /** @@ -629,6 +634,33 @@ public function getUniverseDomain(?callable $httpHandler = null): string return $this->universeDomain; } + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + + if ($this->enableTrustBoundary) { + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->getClientName($httpHandler), + $this->getUniverseDomain($httpHandler), + $httpHandler, + ); + } + + return $metadata; + } + /** * Fetch the value of a GCE metadata server URI. * diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index f4a339b2b..680ba1570 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,6 +26,9 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; +use Google\Auth\TrustBoundaryTrait; +use Google\Auth\UpdateMetadataInterface; +use Google\Auth\UpdateMetadataTrait; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; use LogicException; @@ -41,10 +44,13 @@ */ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface, - GetUniverseDomainInterface + GetUniverseDomainInterface, + UpdateMetadataInterface { use CacheTrait; use IamSignerTrait; + use UpdateMetadataTrait; + use TrustBoundaryTrait; private const CRED_TYPE = 'imp'; private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; @@ -95,6 +101,7 @@ public function __construct( string|array $jsonKey, private ?string $targetAudience = null, string|array|null $defaultScope = null, + bool $enableTrustBoundary = false ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -135,7 +142,10 @@ public function __construct( } $jsonKey['source_credentials'] = match ($jsonKey['source_credentials']['type'] ?? null) { // Do not pass $defaultScope to ServiceAccountCredentials - 'service_account' => new ServiceAccountCredentials($scope, $jsonKey['source_credentials']), + 'service_account' => new ServiceAccountCredentials( + scope: $scope, + jsonKey: $jsonKey['source_credentials'], + ), 'authorized_user' => new UserRefreshCredentials($scope, $jsonKey['source_credentials']), 'external_account' => new ExternalAccountCredentials($scope, $jsonKey['source_credentials']), default => throw new \InvalidArgumentException('invalid value in the type field'), @@ -152,6 +162,7 @@ public function __construct( ); $this->sourceCredentials = $jsonKey['source_credentials']; + $this->enableTrustBoundary = $enableTrustBoundary; } /** @@ -298,4 +309,29 @@ public function getUniverseDomain(): string ? $this->sourceCredentials->getUniverseDomain() : self::DEFAULT_UNIVERSE_DOMAIN; } + + /** + * Updates metadata with the authorization token. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + public function updateMetadata( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { + $metatadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + + $metatadata = $this->updateTrustBoundaryMetadata( + $metatadata, + $this->impersonatedServiceAccountName, + $this->getUniverseDomain(), + $httpHandler, + ); + + return $metatadata; + } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 3d23f71af..da1013bb1 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -20,11 +20,14 @@ use Firebase\JWT\JWT; use Google\Auth\CredentialsLoader; use Google\Auth\GetQuotaProjectInterface; +use Google\Auth\HttpHandler\HttpClientCache; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\Iam; use Google\Auth\OAuth2; use Google\Auth\ProjectIdProviderInterface; use Google\Auth\ServiceAccountSignerTrait; use Google\Auth\SignBlobInterface; +use Google\Auth\TrustBoundaryTrait; use InvalidArgumentException; /** @@ -66,6 +69,7 @@ class ServiceAccountCredentials extends CredentialsLoader implements ProjectIdProviderInterface { use ServiceAccountSignerTrait; + use TrustBoundaryTrait; /** * Used in observability metric headers @@ -130,12 +134,14 @@ class ServiceAccountCredentials extends CredentialsLoader implements * @param string $sub an email address account to impersonate, in situations when * the service account has been delegated domain wide access. * @param string $targetAudience The audience for the ID token. + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. */ public function __construct( $scope, $jsonKey, $sub = null, - $targetAudience = null + $targetAudience = null, + bool $enableTrustBoundary = false ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -183,6 +189,7 @@ public function __construct( $this->projectId = $jsonKey['project_id'] ?? null; $this->universeDomain = $jsonKey['universe_domain'] ?? self::DEFAULT_UNIVERSE_DOMAIN; + $this->enableTrustBoundary = $enableTrustBoundary; } /** @@ -214,9 +221,11 @@ public function useJwtAccessWithScope() */ public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + if ($this->useSelfSignedJwt()) { $jwtCreds = $this->createJwtAccessCredentials(); - $accessToken = $jwtCreds->fetchAuthToken($httpHandler); if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { @@ -317,25 +326,48 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - // scope exists. use oauth implementation - if (!$this->useSelfSignedJwt()) { - return parent::updateMetadata($metadata, $authUri, $httpHandler); - } + $metadata = $this->useSelfSignedJwt() + ? $this->updateMetadataSelfSignedJwt($metadata, $authUri, $httpHandler) + : parent::updateMetadata($metadata, $authUri, $httpHandler); + + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->auth->getIssuer(), + $this->getUniverseDomain(), + $httpHandler, + ); + return $metadata; + } + + /** + * Updates metadata with the authorization token for SSJWTs. + * + * @param array $metadata metadata hashmap + * @param string $authUri optional auth uri + * @param callable|null $httpHandler callback which delivers psr7 request + * @return array updated metadata hashmap + */ + private function updateMetadataSelfSignedJwt( + $metadata, + $authUri = null, + ?callable $httpHandler = null + ) { $jwtCreds = $this->createJwtAccessCredentials(); - if ($this->auth->getScope()) { + + $metadata = $jwtCreds->updateMetadata( + $metadata, // Prefer user-provided "scope" to "audience" - $updatedMetadata = $jwtCreds->updateMetadata($metadata, null, $httpHandler); - } else { - $updatedMetadata = $jwtCreds->updateMetadata($metadata, $authUri, $httpHandler); - } + $this->auth->getScope() ? null : $authUri, + $httpHandler + ); if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { // Keep self-signed JWTs in memory as the last received token $this->lastReceivedJwtAccessToken = $lastReceivedToken; } - return $updatedMetadata; + return $metadata; } /** diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 118f3a902..d90c86ef0 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -152,12 +152,14 @@ public static function fromWellKnownFile() * @param string|string[] $scope * @param array $jsonKey * @param string|string[] $defaultScope + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials */ public static function makeCredentials( $scope, array $jsonKey, - $defaultScope = null + $defaultScope = null, + bool $enableTrustBoundary = false ) { if (!array_key_exists('type', $jsonKey)) { throw new \InvalidArgumentException('json key is missing the type field'); @@ -165,7 +167,7 @@ public static function makeCredentials( if ($jsonKey['type'] == 'service_account') { // Do not pass $defaultScope to ServiceAccountCredentials - return new ServiceAccountCredentials($scope, $jsonKey); + return new ServiceAccountCredentials($scope, $jsonKey, enableTrustBoundary: $enableTrustBoundary); } if ($jsonKey['type'] == 'authorized_user') { @@ -174,7 +176,12 @@ public static function makeCredentials( } if ($jsonKey['type'] == 'impersonated_service_account') { - return new ImpersonatedServiceAccountCredentials($scope, $jsonKey, null, $defaultScope); + return new ImpersonatedServiceAccountCredentials( + $scope, + $jsonKey, + defaultScope: $defaultScope, + enableTrustBoundary: $enableTrustBoundary + ); } if ($jsonKey['type'] == 'external_account') { diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php new file mode 100644 index 000000000..8c290d23b --- /dev/null +++ b/src/TrustBoundaryTrait.php @@ -0,0 +1,122 @@ + $headers + * @return null|array{locations: array, encodedLocations: string} + */ + private function getTrustBoundary( + string $universeDomain, + callable $httpHandler, + string $serviceAccountEmail, + array $headers, + ): array|null { + if (!$this->enableTrustBoundary) { + // Only look up the trust boundary if the credentials have been configured to do so + return null; + } + + if ($universeDomain !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { + // Universe domain is not default, so trust boundary is not supported. + return null; + } + + // Return cached value if it exists + if ($cached = $this->getCachedValue($this->getCacheKey() . ':trustboundary')) { + return $cached; + } + + if (!array_key_exists('authorization', $headers)) { + // If we don't have an authorization token we can't look up the trust boundary + return null; + } + + $trustBoundary = $this->lookupTrustBoundary( + $httpHandler, + $serviceAccountEmail, + $headers['authorization'] + ); + + if (null !== $trustBoundary && !array_key_exists('encodedLocations', $trustBoundary)) { + throw new \LogicException('Trust boundary lookup failed to return \'encodedLocations\''); + } + + // Save to cache + $this->setCachedValue($this->getCacheKey() . ':trustboundary', $trustBoundary); + + return $trustBoundary; + } + + /** + * @param array $authHeader + * @return null|array{locations: array, encodedLocations: string} + */ + private function lookupTrustBoundary( + callable $httpHandler, + string $serviceAccountEmail, + array $authHeader + ): array|null { + $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); + $request = new Request('GET', $url); + $request = $request->withHeader('authorization', $authHeader); + try { + $response = $httpHandler($request); + return json_decode((string) $response->getBody(), true); + } catch (ClientException $e) { + // We swallow all errors here - a failed trust boundary lookup + // should not disrupt client authentication. + } + return null; + } + + private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): string + { + return sprintf( + 'https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s/allowedLocations', + $this->getUniverseDomain(), + $serviceAccountEmail + ); + } + + /** + * @param array $headers + * @return array + */ + private function updateTrustBoundaryMetadata( + array $headers, + string $serviceAccountEmail, + string $universeDomain, + ?callable $httpHandler, + ): array { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + $trustBoundaryInfo = $this->getTrustBoundary( + $universeDomain, + $httpHandler, + $serviceAccountEmail, + $headers + ); + + if ($trustBoundaryInfo) { + $headers['x-allowed-locations'] = $trustBoundaryInfo['encodedLocations']; + } + + return $headers; + } +} diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index d973a1689..f779fd20b 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -29,6 +29,11 @@ use Google\Auth\FetchAuthTokenCache; use Google\Auth\GCECache; use Google\Auth\Logging\StdOutLogger; +use Google\Auth\Middleware\AuthTokenMiddleware; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Psr7; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; @@ -894,6 +899,51 @@ public function testUniverseDomainInGceCredentials() new Response(404), ]), // $httpHandler ); - $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain($httpHandler)); + $this->assertEquals( + CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, + $creds2->getUniverseDomain($httpHandler) + ); + } + + public function testTrustBoundaryLookupIntegration() + { + if ('true' !== getenv('RUN_TRUST_BOUNDARY_TESTS')) { + $this->markTestSkipped( + 'This test requires RUN_TRUST_BOUNDARY_TESTS=true and a set of credentials with' . + 'Trust boundaries enabled' + ); + } + + $creds = ApplicationDefaultCredentials::getCredentials( + 'https://www.googleapis.com/auth/cloud-platform', + enableTrustBoundary: true, + ); + + $mock = new MockHandler([ + new Response(200, [], '{"status":"it worked!"}') // response from KMS + ]); + + $container = []; + $history = Middleware::history($container); + + $middleware = new AuthTokenMiddleware($creds); + $stack = HandlerStack::create($mock); + $stack->push($middleware); + $stack->push($history); + + $client = new Client([ + 'handler' => $stack, + 'auth' => 'google_auth' + ]); + + $res = $client->get('https://fake.url/'); + $this->assertEquals('{"status":"it worked!"}', (string) $res->getBody()); + + $this->assertCount(1, $container); + $this->assertArrayHasKey('request', $container[0]); + + $request = $container[0]['request']; + $this->assertTrue($request->hasHeader('x-allowed-locations')); + $this->assertEquals('0x80000000000', $request->getHeaderLine('x-allowed-locations')); } } diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index ab7353ce3..8623813cc 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -20,6 +20,7 @@ use COM; use Exception; use Google\Auth\Credentials\GCECredentials; +use Google\Auth\GetUniverseDomainInterface; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\Tests\BaseTest; use GuzzleHttp\Exception\ClientException; @@ -205,6 +206,9 @@ public function testOnAppEngineFlexIsFalseByDefault() $this->assertFalse(GCECredentials::onAppEngineFlexible()); } + /** + * @runInSeparateProcess + */ public function testOnAppEngineFlexIsTrueWhenGaeInstanceHasAefPrefix() { putenv('GAE_INSTANCE=aef-default-20180313t154438'); @@ -300,6 +304,7 @@ public function testSettingBothScopeAndTargetAudienceThrowsException() /** * @dataProvider scopes + * @runInSeparateProcess */ public function testFetchAuthTokenCustomScope($scope, $expected) { @@ -387,6 +392,9 @@ public function testGetClientNameShouldBeEmptyIfNotOnGCE() $this->assertEquals('', $creds->getClientName($httpHandler)); } + /** + * @runInSeparateProcess + */ public function testSignBlob() { $expectedEmail = 'test@test.com'; @@ -418,6 +426,9 @@ public function testSignBlob() $signature = $creds->signBlob($stringToSign); } + /** + * @runInSeparateProcess + */ public function testSignBlobWithLastReceivedAccessToken() { $expectedEmail = 'test@test.com'; @@ -459,6 +470,9 @@ public function testSignBlobWithLastReceivedAccessToken() $signature = $creds->signBlob($stringToSign); } + /** + * @runInSeparateProcess + */ public function testSignBlobWithUniverseDomain() { $token = [ @@ -497,6 +511,9 @@ public function testSignBlobWithUniverseDomain() $this->assertEquals('abc123', $signature); } + /** + * @runInSeparateProcess + */ public function testGetProjectId() { $expected = 'foobar'; @@ -518,6 +535,9 @@ public function testGetProjectId() $this->assertEquals($expected, $creds->getProjectId()); } + /** + * @runInSeparateProcess + */ public function testGetProjectIdShouldBeEmptyIfNotOnGCE() { // simulate retry attempts by returning multiple 500s @@ -697,4 +717,48 @@ public function testExplicitUniverseDomain() $creds = new GCECredentials(null, null, null, null, null, $expected); $this->assertEquals($expected, $creds->getUniverseDomain()); } + + public function testUpdateMetadataWithTrustBoundary() + { + $timesCalled = 0; + $httpHandler = function () use (&$timesCalled) { + return match (++$timesCalled) { + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], '{}'), + 4 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + }; + }; + + $gceCreds = new GCECredentials( + enableTrustBoundary: true, + universeDomain: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + ); + + $metadata = $gceCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain() + { + $timesCalled = 0; + $httpHandler = function () use (&$timesCalled) { + return match (++$timesCalled) { + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], '{}'), + }; + }; + + $gceCreds = new GCECredentials( + enableTrustBoundary: true, + universeDomain: 'foo.com' + ); + + $metadata = $gceCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } } diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 1c8a1d9e3..bf0041392 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -550,4 +550,58 @@ public function provideScopePrecedence() [[], '', $defaultScope, 'expectedScope' => $defaultScope], ]; } + + public function testUpdateMetadataWithTrustBoundary() + { + $httpHandler = getHandler([ + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2026-01-01"}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', + 'source_credentials' => [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + ], + ]; + $impersonatedCreds = new ImpersonatedServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableTrustBoundary: true + ); + + $metadata = $impersonatedCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain() + { + $httpHandler = getHandler([ + new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2026-01-01"}'), + ]); + + $jsonKey = [ + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', + 'source_credentials' => [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + 'universe_domain' => 'foo.com' + ], + ]; + $impersonatedCreds = new ImpersonatedServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableTrustBoundary: true + ); + + $metadata = $impersonatedCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } } diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 989179b2f..d46f457e7 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -424,4 +424,47 @@ public function testGetQuotaProject() $sa = new ServiceAccountCredentials('scope/1', $keyFile); $this->assertEquals('test_quota_project', $sa->getQuotaProject()); } + + public function testUpdateMetadataWithTrustBoundary() + { + $httpHandler = getHandler([ + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableTrustBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } + + public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain() + { + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + 'universe_domain' => 'foo.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableTrustBoundary: true + ); + + $metadata = $serviceAccountCreds->updateMetadata([]); + + $this->assertArrayNotHasKey('x-allowed-locations', $metadata); + } } diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index 47e2796ce..54298a31f 100644 --- a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php +++ b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -552,4 +552,28 @@ public function testUpdateMetadataWithUniverseDomainAlwaysUsesJwtAccess() $this->assertArrayHasKey('scope', $json); $this->assertEquals($json['scope'], implode(' ', $scope)); } + + public function testUpdateMetadataWithTrustBoundary() + { + $httpHandler = getHandler([ + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), + ]); + + $jsonKey = [ + 'type' => 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + ]; + $serviceAccountCreds = new ServiceAccountCredentials( + 'a-scope', + $jsonKey, + enableTrustBoundary: true + ); + $serviceAccountCreds->useJwtAccessWithScope(); + + $metadata = $serviceAccountCreds->updateMetadata([], null, $httpHandler); + + $this->assertArrayHasKey('x-allowed-locations', $metadata); + $this->assertEquals('foo', $metadata['x-allowed-locations']); + } } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php new file mode 100644 index 000000000..9f4035b31 --- /dev/null +++ b/tests/TrustBoundaryTraitTest.php @@ -0,0 +1,109 @@ +impl = new TrustBoundaryTraitImpl(); + } + + public function testBuildTrustBoundaryLookupUrl() + { + $url = $this->impl->buildTrustBoundaryLookupUrl('test@example.com'); + $this->assertEquals( + 'https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/test@example.com/allowedLocations', + $url + ); + } + + public function testLookupTrustBoundary() + { + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "enodedLocations": ""0xA30"}'; + $mock = new MockHandler([ + new Response(200, [], $responseBody), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + $result = $this->impl->lookupTrustBoundary($handler, 'default', []); + $this->assertEquals(json_decode($responseBody, true), $result); + } + + public function testLookupTrustBoundary404() + { + $mock = new MockHandler([ + new Response(404), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + $result = $this->impl->lookupTrustBoundary($handler, 'default', []); + $this->assertNull($result); + } + + public function testRefreshTrustBoundaryWithCache() + { + $cache = new MemoryCacheItemPool(); + $this->impl->setCache($cache); + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "enodedLocations": ""0xA30"}'; + $mock = new MockHandler([ + new Response(200, [], $responseBody), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default', []); + $this->assertEquals(json_decode($responseBody, true), $result1); + + // Second call, should return from cache + $mock->reset(); + $mock->append(new Response(500)); // This should not be called + $result2 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default', []); + $this->assertEquals(json_decode($responseBody, true), $result2); + } +} + +class TrustBoundaryTraitImpl +{ + use TrustBoundaryTrait { + buildTrustBoundaryLookupUrl as public; + lookupTrustBoundary as public; + getTrustBoundary as public; + } + + private $cache; + private $cacheConfig; + + public function __construct(array $config = []) + { + $this->cacheConfig = [ + 'prefix' => '', + 'lifetime' => 1000, + ]; + } + + public function getCacheKey() + { + return 'test-key'; + } + + public function setCache($cache) + { + $this->cache = $cache; + } + + public function getUniverseDomain() + { + return 'foo.bar'; + } +}