From 6413043e74e36067b0e7ef8aec463d1a49ee461a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 30 Jan 2026 08:36:37 -0800 Subject: [PATCH 01/12] initial port of trust boundaries --- .../ImpersonatedServiceAccountCredentials.php | 16 ++- src/Credentials/ServiceAccountCredentials.php | 22 +++- .../ServiceAccountJwtAccessCredentials.php | 5 + src/TrustBoundaryInterface.php | 14 +++ src/TrustBoundaryTrait.php | 74 +++++++++++++ ...ccountCredentialsWithTrustBoundaryTest.php | 70 ++++++++++++ ...ccountCredentialsWithTrustBoundaryTest.php | 93 ++++++++++++++++ tests/TrustBoundaryTraitTest.php | 102 ++++++++++++++++++ 8 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 src/TrustBoundaryInterface.php create mode 100644 src/TrustBoundaryTrait.php create mode 100644 tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php create mode 100644 tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php create mode 100644 tests/TrustBoundaryTraitTest.php diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index f4a339b2bf..2375c1ff5d 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,6 +26,8 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; +use Google\Auth\TrustBoundaryInterface; +use Google\Auth\TrustBoundaryTrait; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; use LogicException; @@ -41,10 +43,12 @@ */ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface, - GetUniverseDomainInterface + GetUniverseDomainInterface, + TrustBoundaryInterface { use CacheTrait; use IamSignerTrait; + use TrustBoundaryTrait; private const CRED_TYPE = 'imp'; private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; @@ -215,6 +219,16 @@ public function fetchAuthToken(?callable $httpHandler = null) 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), ], $this->isIdTokenRequest() ? 'it' : 'at'); + if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + // Universe domain is not default, so trust boundary is not supported. + $this->suppressTrustBoundary(); + } + + if ($trustBoundaryInfo = $this->refreshTrustBoundary($httpHandler)) { + $headers['x-goog-iam-authorization-token'] = $trustBoundaryInfo['token']; + $headers['x-goog-iam-authority-selector'] = $trustBoundaryInfo['authority_selector']; + } + $body = match ($this->isIdTokenRequest()) { true => [ 'audience' => $this->targetAudience, diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 3d23f71af9..37fca5111b 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -25,6 +25,8 @@ use Google\Auth\ProjectIdProviderInterface; use Google\Auth\ServiceAccountSignerTrait; use Google\Auth\SignBlobInterface; +use Google\Auth\TrustBoundaryInterface; +use Google\Auth\TrustBoundaryTrait; use InvalidArgumentException; /** @@ -63,9 +65,11 @@ class ServiceAccountCredentials extends CredentialsLoader implements GetQuotaProjectInterface, SignBlobInterface, - ProjectIdProviderInterface + ProjectIdProviderInterface, + TrustBoundaryInterface { use ServiceAccountSignerTrait; + use TrustBoundaryTrait; /** * Used in observability metric headers @@ -214,8 +218,24 @@ public function useJwtAccessWithScope() */ public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) { + if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + // Universe domain is not default, so trust boundary is not supported. + $this->suppressTrustBoundary(); + } + + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if ($trustBoundaryInfo = $this->refreshTrustBoundary($httpHandler, $this->auth->getIssuer())) { + $this->auth->setAdditionalClaims([ + 'x-goog-iam-authorization-token' => $trustBoundaryInfo['token'], + 'x-goog-iam-authority-selector' => $trustBoundaryInfo['authority_selector'], + ]); + } + if ($this->useSelfSignedJwt()) { $jwtCreds = $this->createJwtAccessCredentials(); + $jwtCreds->setAdditionalClaims($this->auth->getAdditionalClaims()); $accessToken = $jwtCreds->fetchAuthToken($httpHandler); diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index 50373760b9..a03198a851 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -109,6 +109,11 @@ public function __construct($jsonKey, $scope = null) $this->projectId = $jsonKey['project_id'] ?? null; } + public function setAdditionalClaims(array $claims) + { + $this->auth->setAdditionalClaims($claims); + } + /** * Updates metadata with the authorization token. * diff --git a/src/TrustBoundaryInterface.php b/src/TrustBoundaryInterface.php new file mode 100644 index 0000000000..c7925bc21b --- /dev/null +++ b/src/TrustBoundaryInterface.php @@ -0,0 +1,14 @@ +isTrustBoundarySuppressed = true; + } + + public function isTrustBoundarySuppressed() + { + return $this->isTrustBoundarySuppressed; + } + + private function refreshTrustBoundary(callable $httpHandler, string $serviceAccountEmail = 'default') + { + if ($this->isTrustBoundarySuppressed()) { + return; + } + + // Return cached value if it exists + if ($cached = $this->getCachedValue($this->getCacheKey() . ':trustboundary')) { + return $cached; + } + + $token = $this->lookupTrustBoundary($httpHandler, $serviceAccountEmail); + + // Save to cache + $this->setCachedValue($this->getCacheKey() . ':trustboundary', $token); + + return $token; + } + + private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail) + { + $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); + $request = new Request('GET', $url, ['Metadata-Flavor' => 'Google']); + try { + $response = $httpHandler($request); + return json_decode((string) $response->getBody(), true); + } catch (ClientException $e) { + // We swallow 404s here. This is because we reasonably expect 404s + // to be returned from the metadata server for service accounts + // that do not exist or do not have the required permissions. + if ($e->getResponse()->getStatusCode() !== 404) { + throw $e; + } + } + } + + private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail) + { + $metadataHost = getenv('GCE_METADATA_HOST') ?: '169.254.169.254'; + return sprintf( + 'http://%s/computeMetadata/v1/instance/service-accounts/%s/?recursive=true', + $metadataHost, + $serviceAccountEmail + ); + } +} diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php new file mode 100644 index 0000000000..3dc304b631 --- /dev/null +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php @@ -0,0 +1,70 @@ + 'service_account', + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + ]; + } + + public function testFetchAuthTokenWithTrustBoundary() + { + $sourceTokenResponse = new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'); + $trustBoundaryResponse = new Response(200, [], '{"token": "my-token", "authority_selector": "my-selector"}'); + $impersonationResponse = new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2025-01-01T00:00:00Z"}'); + + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler([$sourceTokenResponse, $trustBoundaryResponse, $impersonationResponse]); + $stack = new HandlerStack($mock); + $stack->push($history); + $client = new Client(['handler' => $stack]); + $handler = function ($request) use ($client) { + return $client->send($request); + }; + + $sourceCreds = new ServiceAccountCredentials('scope', $this->createTestJson()); + + $impersonatedCreds = new ImpersonatedServiceAccountCredentials( + ['scope'], + [ + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/impersonated@example.com:generateAccessToken', + 'source_credentials' => $sourceCreds, + ] + ); + + $impersonatedCreds->fetchAuthToken($handler); + + $this->assertCount(3, $container); + + // First request is for source token + $sourceTokenRequest = $container[0]['request']; + $this->assertEquals('https://oauth2.googleapis.com/token', (string) $sourceTokenRequest->getUri()); + + // Second request is for trust boundary + $trustBoundaryRequest = $container[1]['request']; + $this->assertStringContainsString('/computeMetadata/v1/instance/service-accounts/default/?recursive=true', (string) $trustBoundaryRequest->getUri()); + + // Third request is for impersonation + $impersonationRequest = $container[2]['request']; + $this->assertTrue($impersonationRequest->hasHeader('x-goog-iam-authorization-token')); + $this->assertEquals('my-token', $impersonationRequest->getHeaderLine('x-goog-iam-authorization-token')); + $this->assertTrue($impersonationRequest->hasHeader('x-goog-iam-authority-selector')); + $this->assertEquals('my-selector', $impersonationRequest->getHeaderLine('x-goog-iam-authority-selector')); + } +} diff --git a/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php new file mode 100644 index 0000000000..b722654a26 --- /dev/null +++ b/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php @@ -0,0 +1,93 @@ + file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'client_email' => 'test@example.com', + ]; + } + + public function testFetchAuthTokenWithTrustBoundary() + { + $trustBoundaryResponse = new Response(200, [], '{"token": "my-token", "authority_selector": "my-selector"}'); + $accessTokenResponse = new Response(200, [], '{"access_token": "my-access-token", "expires_in": 3600}'); + + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler([$trustBoundaryResponse, $accessTokenResponse]); + $stack = new HandlerStack($mock); + $stack->push($history); + $client = new Client(['handler' => $stack]); + + $creds = new ServiceAccountCredentials('scope', $this->createTestJson()); + $creds->fetchAuthToken(function ($request) use ($client) { + return $client->send($request); + }); + + $this->assertCount(2, $container); + + // First request is for trust boundary + $trustBoundaryRequest = $container[0]['request']; + $this->assertStringContainsString('/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', (string) $trustBoundaryRequest->getUri()); + + // Second request is for access token + $accessTokenRequest = $container[1]['request']; + $body = (string) $accessTokenRequest->getBody(); + parse_str($body, $params); + $this->assertArrayHasKey('assertion', $params); + $jwt = $params['assertion']; + list($header, $payload, $signature) = explode('.', $jwt); + $payload = json_decode(base64_decode($payload), true); + + $this->assertArrayHasKey('x-goog-iam-authorization-token', $payload); + $this->assertEquals('my-token', $payload['x-goog-iam-authorization-token']); + $this->assertArrayHasKey('x-goog-iam-authority-selector', $payload); + $this->assertEquals('my-selector', $payload['x-goog-iam-authority-selector']); + } + + public function testFetchAuthTokenWithTrustBoundarySuppressed() + { + $accessTokenResponse = new Response(200, [], '{"access_token": "my-access-token", "expires_in": 3600}'); + + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler([$accessTokenResponse]); + $stack = new HandlerStack($mock); + $stack->push($history); + $client = new Client(['handler' => $stack]); + + $json = $this->createTestJson(); + $json['universe_domain'] = 'my-universe.com'; + $creds = new ServiceAccountCredentials('scope', $json); + + $creds->fetchAuthToken(function ($request) use ($client) { + return $client->send($request); + }); + + $this->assertCount(1, $container); + + $accessTokenRequest = $container[0]['request']; + $body = (string) $accessTokenRequest->getBody(); + parse_str($body, $params); + $this->assertArrayHasKey('assertion', $params); + $jwt = $params['assertion']; + list($header, $payload, $signature) = explode('.', $jwt); + $payload = json_decode(base64_decode($payload), true); + + $this->assertArrayNotHasKey('x-goog-iam-authorization-token', $payload); + $this->assertArrayNotHasKey('x-goog-iam-authority-selector', $payload); + } +} diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php new file mode 100644 index 0000000000..fb9ec7693e --- /dev/null +++ b/tests/TrustBoundaryTraitTest.php @@ -0,0 +1,102 @@ +impl = new TrustBoundaryTraitImpl(); + } + + public function testBuildTrustBoundaryLookupUrl() + { + $url = $this->impl->buildTrustBoundaryLookupUrlPublic('test@example.com'); + $this->assertEquals( + 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', + $url + ); + } + + public function testLookupTrustBoundary() + { + $responseBody = '{"token": "my-token", "authority_selector": "my-selector"}'; + $mock = new MockHandler([ + new Response(200, [], $responseBody), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + $result = $this->impl->lookupTrustBoundaryPublic($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->lookupTrustBoundaryPublic($handler, 'default'); + $this->assertNull($result); + } + + public function testRefreshTrustBoundaryWithCache() + { + $cache = new MemoryCacheItemPool(); + $this->impl->setCache($cache); + $responseBody = '{"token": "my-token", "authority_selector": "my-selector"}'; + $mock = new MockHandler([ + new Response(200, [], $responseBody), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + // First call, should fetch and cache + $result1 = $this->impl->refreshTrustBoundaryPublic($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->refreshTrustBoundaryPublic($handler, 'default'); + $this->assertEquals(json_decode($responseBody, true), $result2); + } +} + +class TrustBoundaryTraitImpl +{ + use TrustBoundaryTrait { + buildTrustBoundaryLookupUrl as public buildTrustBoundaryLookupUrlPublic; + lookupTrustBoundary as public lookupTrustBoundaryPublic; + refreshTrustBoundary as public refreshTrustBoundaryPublic; + } + + 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; + } +} From a4bf7486718eddb6a5e75706918212d1c7f531b7 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 30 Jan 2026 14:34:34 -0800 Subject: [PATCH 02/12] add enableTrustBoundary auth param, fix tests --- src/ApplicationDefaultCredentials.php | 21 +++++++++++--- .../ImpersonatedServiceAccountCredentials.php | 11 +++++++- src/Credentials/ServiceAccountCredentials.php | 9 +++++- .../ServiceAccountJwtAccessCredentials.php | 6 ++++ src/CredentialsLoader.php | 12 ++++++-- ...ersonatedServiceAccountCredentialsTest.php | 2 +- ...ccountCredentialsWithTrustBoundaryTest.php | 10 +++++-- ...ccountCredentialsWithTrustBoundaryTest.php | 28 ++++++------------- 8 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index a64af46a94..e580b84d18 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -166,6 +166,7 @@ public static function getCredentials( $defaultScope = null, ?string $universeDomain = null, null|false|LoggerInterface $logger = null, + bool $enableTrustBoundary = false ) { $creds = null; $jsonKey = CredentialsLoader::fromEnv() @@ -196,7 +197,8 @@ public static function getCredentials( $creds = CredentialsLoader::makeCredentials( $scope, $jsonKey, - $defaultScope + $defaultScope, + $enableTrustBoundary ); } elseif (AppIdentityCredentials::onAppEngine() && !GCECredentials::onAppEngineFlexible()) { $creds = new AppIdentityCredentials($anyScope); @@ -286,7 +288,8 @@ public static function getIdTokenCredentials( $targetAudience, ?callable $httpHandler = null, ?array $cacheConfig = null, - ?CacheItemPoolInterface $cache = null + ?CacheItemPoolInterface $cache = null, + bool $enableTrustBoundary = false ) { $creds = null; $jsonKey = CredentialsLoader::fromEnv() @@ -308,8 +311,18 @@ 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, + enableTrustBoundary: $enableTrustBoundary + ), + 'service_account' => new ServiceAccountCredentials( + scope: null, + jsonKey: $jsonKey, + targetAudience: $targetAudience, + enableTrustBoundary: $enableTrustBoundary + ), default => throw new InvalidArgumentException('invalid value in the type field') }; } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 2375c1ff5d..4a77f12999 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -99,6 +99,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)) { @@ -139,7 +140,11 @@ 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'], + enableTrustBoundary: $enableTrustBoundary + ), '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'), @@ -156,6 +161,10 @@ public function __construct( ); $this->sourceCredentials = $jsonKey['source_credentials']; + + if (!$enableTrustBoundary) { + $this->suppressTrustBoundary(); + } } /** diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 37fca5111b..3c537db079 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -20,6 +20,8 @@ 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; @@ -139,7 +141,8 @@ public function __construct( $scope, $jsonKey, $sub = null, - $targetAudience = null + $targetAudience = null, + $enableTrustBoundary = false ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -187,6 +190,10 @@ public function __construct( $this->projectId = $jsonKey['project_id'] ?? null; $this->universeDomain = $jsonKey['universe_domain'] ?? self::DEFAULT_UNIVERSE_DOMAIN; + + if (!$enableTrustBoundary) { + $this->suppressTrustBoundary(); + } } /** diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index a03198a851..a946cf50d3 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -109,6 +109,12 @@ public function __construct($jsonKey, $scope = null) $this->projectId = $jsonKey['project_id'] ?? null; } + /** + * Sets additional claims to be included in the JWT token + * + * @param array $additionalClaims + * @return void + */ public function setAdditionalClaims(array $claims) { $this->auth->setAdditionalClaims($claims); diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 118f3a902d..f47d4634ab 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -157,7 +157,8 @@ public static function fromWellKnownFile() public static function makeCredentials( $scope, array $jsonKey, - $defaultScope = null + $defaultScope = null, + $enableTrustBoundary = false ) { if (!array_key_exists('type', $jsonKey)) { throw new \InvalidArgumentException('json key is missing the type field'); @@ -165,7 +166,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 +175,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/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 1c8a1d9e39..46c72f5572 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -403,7 +403,7 @@ public function testGetIdTokenWithArbitraryCredentials(?string $universeDomain = ->shouldBeCalledOnce() ->willReturn(['access_token' => 'test-access-token']); $credentials->getUniverseDomain() - ->shouldBeCalledOnce() + ->shouldBeCalledTimes(2) ->willReturn($universeDomain ?: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN); $json = [ diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php index 3dc304b631..9d42bc046d 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php @@ -43,9 +43,10 @@ public function testFetchAuthTokenWithTrustBoundary() $impersonatedCreds = new ImpersonatedServiceAccountCredentials( ['scope'], [ - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/impersonated@example.com:generateAccessToken', + 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', 'source_credentials' => $sourceCreds, - ] + ], + enableTrustBoundary: true ); $impersonatedCreds->fetchAuthToken($handler); @@ -58,7 +59,10 @@ public function testFetchAuthTokenWithTrustBoundary() // Second request is for trust boundary $trustBoundaryRequest = $container[1]['request']; - $this->assertStringContainsString('/computeMetadata/v1/instance/service-accounts/default/?recursive=true', (string) $trustBoundaryRequest->getUri()); + $this->assertStringContainsString( + '/computeMetadata/v1/instance/service-accounts/default/?recursive=true', + (string) $trustBoundaryRequest->getUri() + ); // Third request is for impersonation $impersonationRequest = $container[2]['request']; diff --git a/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php index b722654a26..b75e6ccdcf 100644 --- a/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php +++ b/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php @@ -32,7 +32,7 @@ public function testFetchAuthTokenWithTrustBoundary() $stack->push($history); $client = new Client(['handler' => $stack]); - $creds = new ServiceAccountCredentials('scope', $this->createTestJson()); + $creds = new ServiceAccountCredentials('scope', $this->createTestJson(), enableTrustBoundary: true); $creds->fetchAuthToken(function ($request) use ($client) { return $client->send($request); }); @@ -41,7 +41,10 @@ public function testFetchAuthTokenWithTrustBoundary() // First request is for trust boundary $trustBoundaryRequest = $container[0]['request']; - $this->assertStringContainsString('/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', (string) $trustBoundaryRequest->getUri()); + $this->assertStringContainsString( + '/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', + (string) $trustBoundaryRequest->getUri() + ); // Second request is for access token $accessTokenRequest = $container[1]['request']; @@ -58,36 +61,23 @@ public function testFetchAuthTokenWithTrustBoundary() $this->assertEquals('my-selector', $payload['x-goog-iam-authority-selector']); } - public function testFetchAuthTokenWithTrustBoundarySuppressed() + public function testFetchAuthTokenWithTrustBoundarySuppressedWithUniverseDomain() { - $accessTokenResponse = new Response(200, [], '{"access_token": "my-access-token", "expires_in": 3600}'); - $container = []; $history = Middleware::history($container); - $mock = new MockHandler([$accessTokenResponse]); + $mock = new MockHandler([]); $stack = new HandlerStack($mock); $stack->push($history); $client = new Client(['handler' => $stack]); $json = $this->createTestJson(); $json['universe_domain'] = 'my-universe.com'; - $creds = new ServiceAccountCredentials('scope', $json); + $creds = new ServiceAccountCredentials('scope', $json, enableTrustBoundary: true); $creds->fetchAuthToken(function ($request) use ($client) { return $client->send($request); }); - $this->assertCount(1, $container); - - $accessTokenRequest = $container[0]['request']; - $body = (string) $accessTokenRequest->getBody(); - parse_str($body, $params); - $this->assertArrayHasKey('assertion', $params); - $jwt = $params['assertion']; - list($header, $payload, $signature) = explode('.', $jwt); - $payload = json_decode(base64_decode($payload), true); - - $this->assertArrayNotHasKey('x-goog-iam-authorization-token', $payload); - $this->assertArrayNotHasKey('x-goog-iam-authority-selector', $payload); + $this->assertCount(0, $container); } } From 872cbf9ece80eeaa7bd91ebb7f05bca1fd29caa8 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 3 Feb 2026 11:11:10 -0800 Subject: [PATCH 03/12] fix styles --- src/Credentials/ServiceAccountCredentials.php | 3 ++- src/Credentials/ServiceAccountJwtAccessCredentials.php | 4 ++-- src/CredentialsLoader.php | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 3c537db079..0a2a73bbd4 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -136,13 +136,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 Enable the trust boundary lookup */ public function __construct( $scope, $jsonKey, $sub = null, $targetAudience = null, - $enableTrustBoundary = false + bool $enableTrustBoundary = false ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index a946cf50d3..d37d85875e 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -115,9 +115,9 @@ public function __construct($jsonKey, $scope = null) * @param array $additionalClaims * @return void */ - public function setAdditionalClaims(array $claims) + public function setAdditionalClaims(array $additionalClaims) { - $this->auth->setAdditionalClaims($claims); + $this->auth->setAdditionalClaims($additionalClaims); } /** diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index f47d4634ab..4a0fbc1647 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -152,13 +152,14 @@ public static function fromWellKnownFile() * @param string|string[] $scope * @param array $jsonKey * @param string|string[] $defaultScope + * @param bool $enableTrustBoundary Enable the trust boundary lookup * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials */ public static function makeCredentials( $scope, array $jsonKey, $defaultScope = null, - $enableTrustBoundary = false + bool $enableTrustBoundary = false ) { if (!array_key_exists('type', $jsonKey)) { throw new \InvalidArgumentException('json key is missing the type field'); From ab8497265ec3eb336199c3cfbe006dda38779de5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 3 Feb 2026 14:12:29 -0800 Subject: [PATCH 04/12] more styles fixes --- .../ImpersonatedServiceAccountCredentials.php | 4 +--- src/Credentials/ServiceAccountCredentials.php | 4 +--- src/TrustBoundaryInterface.php | 14 -------------- src/TrustBoundaryTrait.php | 19 +++++++++++-------- 4 files changed, 13 insertions(+), 28 deletions(-) delete mode 100644 src/TrustBoundaryInterface.php diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 4a77f12999..d028ede0b4 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,7 +26,6 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; use Google\Auth\SignBlobInterface; -use Google\Auth\TrustBoundaryInterface; use Google\Auth\TrustBoundaryTrait; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; @@ -43,8 +42,7 @@ */ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface, - GetUniverseDomainInterface, - TrustBoundaryInterface + GetUniverseDomainInterface { use CacheTrait; use IamSignerTrait; diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 0a2a73bbd4..4808bf2428 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -27,7 +27,6 @@ use Google\Auth\ProjectIdProviderInterface; use Google\Auth\ServiceAccountSignerTrait; use Google\Auth\SignBlobInterface; -use Google\Auth\TrustBoundaryInterface; use Google\Auth\TrustBoundaryTrait; use InvalidArgumentException; @@ -67,8 +66,7 @@ class ServiceAccountCredentials extends CredentialsLoader implements GetQuotaProjectInterface, SignBlobInterface, - ProjectIdProviderInterface, - TrustBoundaryInterface + ProjectIdProviderInterface { use ServiceAccountSignerTrait; use TrustBoundaryTrait; diff --git a/src/TrustBoundaryInterface.php b/src/TrustBoundaryInterface.php deleted file mode 100644 index c7925bc21b..0000000000 --- a/src/TrustBoundaryInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -isTrustBoundarySuppressed = true; } - public function isTrustBoundarySuppressed() + private function isTrustBoundarySuppressed() { return $this->isTrustBoundarySuppressed; } - private function refreshTrustBoundary(callable $httpHandler, string $serviceAccountEmail = 'default') - { + private function refreshTrustBoundary( + callable $httpHandler, + string $serviceAccountEmail = 'default' + ): array|null { if ($this->isTrustBoundarySuppressed()) { - return; + return null; } // Return cached value if it exists @@ -45,7 +47,7 @@ private function refreshTrustBoundary(callable $httpHandler, string $serviceAcco return $token; } - private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail) + private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail): array|null { $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); $request = new Request('GET', $url, ['Metadata-Flavor' => 'Google']); @@ -60,9 +62,10 @@ private function lookupTrustBoundary(callable $httpHandler, string $serviceAccou throw $e; } } + return null; } - private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail) + private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): string { $metadataHost = getenv('GCE_METADATA_HOST') ?: '169.254.169.254'; return sprintf( From 8d9f2d0f5ed75a46cf7d9f0016f4f821b73288f7 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 4 Feb 2026 12:21:24 -0800 Subject: [PATCH 05/12] final phpstan fixes? --- src/TrustBoundaryTrait.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index fd75da0917..e4d439363a 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -21,11 +21,15 @@ private function suppressTrustBoundary(): void $this->isTrustBoundarySuppressed = true; } - private function isTrustBoundarySuppressed() + private function isTrustBoundarySuppressed(): bool { return $this->isTrustBoundarySuppressed; } + /** + * @return array{authority_selector: string, token: string}|null + * + */ private function refreshTrustBoundary( callable $httpHandler, string $serviceAccountEmail = 'default' @@ -47,6 +51,9 @@ private function refreshTrustBoundary( return $token; } + /** + * @return array{authority_selector: string, token: string}|null + */ private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail): array|null { $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); From d68df718342eb4899b61f96b5ad0cb6a27ecd321 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Feb 2026 11:32:46 -0800 Subject: [PATCH 06/12] implement trust boundaries for real --- src/Credentials/GCECredentials.php | 29 ++++++- .../ImpersonatedServiceAccountCredentials.php | 51 ++++++++---- src/Credentials/ServiceAccountCredentials.php | 34 +++----- src/TrustBoundaryTrait.php | 58 ++++++++----- ...ersonatedServiceAccountCredentialsTest.php | 54 ++++++++++++ ...ccountCredentialsWithTrustBoundaryTest.php | 74 ----------------- .../ServiceAccountCredentialsTest.php | 43 ++++++++++ ...ccountCredentialsWithTrustBoundaryTest.php | 83 ------------------- ...ServiceAccountJwtAccessCredentialsTest.php | 24 ++++++ tests/TrustBoundaryTraitTest.php | 29 ++++--- 10 files changed, 249 insertions(+), 230 deletions(-) delete mode 100644 tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php delete mode 100644 tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index ab6753bd81..073dc351ec 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 [optional] Enable the trust boundary lookup. */ 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,28 @@ 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 = $this->updateTrustBoundaryMetadata( + $metadata, + $this->serviceAccountIdentity ?: 'default', + $httpHandler, + ); + + return parent::updateMetadata($metadata, $authUri, $httpHandler); + } + /** * Fetch the value of a GCE metadata server URI. * diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index d028ede0b4..ef00e8a43c 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -27,6 +27,8 @@ 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; @@ -42,10 +44,12 @@ */ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface, - GetUniverseDomainInterface + GetUniverseDomainInterface, + UpdateMetadataInterface { use CacheTrait; use IamSignerTrait; + use UpdateMetadataTrait; use TrustBoundaryTrait; private const CRED_TYPE = 'imp'; @@ -141,7 +145,6 @@ public function __construct( 'service_account' => new ServiceAccountCredentials( scope: $scope, jsonKey: $jsonKey['source_credentials'], - enableTrustBoundary: $enableTrustBoundary ), 'authorized_user' => new UserRefreshCredentials($scope, $jsonKey['source_credentials']), 'external_account' => new ExternalAccountCredentials($scope, $jsonKey['source_credentials']), @@ -159,10 +162,7 @@ public function __construct( ); $this->sourceCredentials = $jsonKey['source_credentials']; - - if (!$enableTrustBoundary) { - $this->suppressTrustBoundary(); - } + $this->enableTrustBoundary = $enableTrustBoundary; } /** @@ -226,16 +226,6 @@ public function fetchAuthToken(?callable $httpHandler = null) 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), ], $this->isIdTokenRequest() ? 'it' : 'at'); - if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { - // Universe domain is not default, so trust boundary is not supported. - $this->suppressTrustBoundary(); - } - - if ($trustBoundaryInfo = $this->refreshTrustBoundary($httpHandler)) { - $headers['x-goog-iam-authorization-token'] = $trustBoundaryInfo['token']; - $headers['x-goog-iam-authority-selector'] = $trustBoundaryInfo['authority_selector']; - } - $body = match ($this->isIdTokenRequest()) { true => [ 'audience' => $this->targetAudience, @@ -319,4 +309,33 @@ 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 + ) { + if ($this->enableTrustBoundary) { + if (!$this->sourceCredentials instanceof ServiceAccountCredentials) { + throw new LogicException( + 'Trust boundary lookup is only supported for service account credentials' + ); + } + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->sourceCredentials->getClientName(), + $httpHandler, + ); + } + + return parent::updateMetadata($metadata, $authUri, $httpHandler); + } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 4808bf2428..bdb78a0268 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -189,10 +189,7 @@ public function __construct( $this->projectId = $jsonKey['project_id'] ?? null; $this->universeDomain = $jsonKey['universe_domain'] ?? self::DEFAULT_UNIVERSE_DOMAIN; - - if (!$enableTrustBoundary) { - $this->suppressTrustBoundary(); - } + $this->enableTrustBoundary = $enableTrustBoundary; } /** @@ -224,21 +221,9 @@ public function useJwtAccessWithScope() */ public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) { - if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { - // Universe domain is not default, so trust boundary is not supported. - $this->suppressTrustBoundary(); - } - $httpHandler = $httpHandler ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); - if ($trustBoundaryInfo = $this->refreshTrustBoundary($httpHandler, $this->auth->getIssuer())) { - $this->auth->setAdditionalClaims([ - 'x-goog-iam-authorization-token' => $trustBoundaryInfo['token'], - 'x-goog-iam-authority-selector' => $trustBoundaryInfo['authority_selector'], - ]); - } - if ($this->useSelfSignedJwt()) { $jwtCreds = $this->createJwtAccessCredentials(); $jwtCreds->setAdditionalClaims($this->auth->getAdditionalClaims()); @@ -343,18 +328,23 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->auth->getIssuer(), + $httpHandler, + ); + // scope exists. use oauth implementation if (!$this->useSelfSignedJwt()) { return parent::updateMetadata($metadata, $authUri, $httpHandler); } $jwtCreds = $this->createJwtAccessCredentials(); - if ($this->auth->getScope()) { - // Prefer user-provided "scope" to "audience" - $updatedMetadata = $jwtCreds->updateMetadata($metadata, null, $httpHandler); - } else { - $updatedMetadata = $jwtCreds->updateMetadata($metadata, $authUri, $httpHandler); - } + + // Prefer user-provided "scope" to "audience" + $updatedMetadata = $this->auth->getScope() + ? $jwtCreds->updateMetadata($metadata, null, $httpHandler) + : $jwtCreds->updateMetadata($metadata, $authUri, $httpHandler); if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { // Keep self-signed JWTs in memory as the last received token diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index e4d439363a..1d5b9b8e96 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -14,27 +14,22 @@ trait TrustBoundaryTrait { use CacheTrait; - private bool $isTrustBoundarySuppressed = false; - - private function suppressTrustBoundary(): void - { - $this->isTrustBoundarySuppressed = true; - } - - private function isTrustBoundarySuppressed(): bool - { - return $this->isTrustBoundarySuppressed; - } + private bool $enableTrustBoundary = false; /** - * @return array{authority_selector: string, token: string}|null - * + * @return null|array{locations: array, encodedLocations: string} */ - private function refreshTrustBoundary( - callable $httpHandler, + public function getTrustBoundary( + ?callable $httpHandler = null, string $serviceAccountEmail = 'default' ): array|null { - if ($this->isTrustBoundarySuppressed()) { + if (!$this->enableTrustBoundary) { + // Only look up the trust boundary if the credentials have been configured to do so + return null; + } + + if ($this->getUniverseDomain() !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { + // Universe domain is not default, so trust boundary is not supported. return null; } @@ -43,16 +38,23 @@ private function refreshTrustBoundary( return $cached; } - $token = $this->lookupTrustBoundary($httpHandler, $serviceAccountEmail); + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + $trustBoundary = $this->lookupTrustBoundary($httpHandler, $serviceAccountEmail); + + 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', $token); + $this->setCachedValue($this->getCacheKey() . ':trustboundary', $trustBoundary); - return $token; + return $trustBoundary; } /** - * @return array{authority_selector: string, token: string}|null + * @return null|array{locations: array, encodedLocations: string} */ private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail): array|null { @@ -74,11 +76,21 @@ private function lookupTrustBoundary(callable $httpHandler, string $serviceAccou private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): string { - $metadataHost = getenv('GCE_METADATA_HOST') ?: '169.254.169.254'; return sprintf( - 'http://%s/computeMetadata/v1/instance/service-accounts/%s/?recursive=true', - $metadataHost, + 'https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s/allowedLocations', + $this->getUniverseDomain(), $serviceAccountEmail ); } + + private function updateTrustBoundaryMetadata( + array $headers, + string $serviceAccountEmail, + ?callable $httpHandler = null, + ): array { + if ($trustBoundaryInfo = $this->getTrustBoundary($httpHandler, $serviceAccountEmail)) { + $headers['x-allowed-locations'] = $trustBoundaryInfo['encodedLocations']; + } + return $headers; + } } diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 46c72f5572..55f4468fea 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, [], '{"locations": [], "encodedLocations": "foo"}'), + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + 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', + ], + ]; + $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/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php deleted file mode 100644 index 9d42bc046d..0000000000 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsWithTrustBoundaryTest.php +++ /dev/null @@ -1,74 +0,0 @@ - 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), - 'client_email' => 'test@example.com', - ]; - } - - public function testFetchAuthTokenWithTrustBoundary() - { - $sourceTokenResponse = new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'); - $trustBoundaryResponse = new Response(200, [], '{"token": "my-token", "authority_selector": "my-selector"}'); - $impersonationResponse = new Response(200, [], '{"accessToken": "impersonated-token", "expireTime": "2025-01-01T00:00:00Z"}'); - - $container = []; - $history = Middleware::history($container); - $mock = new MockHandler([$sourceTokenResponse, $trustBoundaryResponse, $impersonationResponse]); - $stack = new HandlerStack($mock); - $stack->push($history); - $client = new Client(['handler' => $stack]); - $handler = function ($request) use ($client) { - return $client->send($request); - }; - - $sourceCreds = new ServiceAccountCredentials('scope', $this->createTestJson()); - - $impersonatedCreds = new ImpersonatedServiceAccountCredentials( - ['scope'], - [ - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', - 'source_credentials' => $sourceCreds, - ], - enableTrustBoundary: true - ); - - $impersonatedCreds->fetchAuthToken($handler); - - $this->assertCount(3, $container); - - // First request is for source token - $sourceTokenRequest = $container[0]['request']; - $this->assertEquals('https://oauth2.googleapis.com/token', (string) $sourceTokenRequest->getUri()); - - // Second request is for trust boundary - $trustBoundaryRequest = $container[1]['request']; - $this->assertStringContainsString( - '/computeMetadata/v1/instance/service-accounts/default/?recursive=true', - (string) $trustBoundaryRequest->getUri() - ); - - // Third request is for impersonation - $impersonationRequest = $container[2]['request']; - $this->assertTrue($impersonationRequest->hasHeader('x-goog-iam-authorization-token')); - $this->assertEquals('my-token', $impersonationRequest->getHeaderLine('x-goog-iam-authorization-token')); - $this->assertTrue($impersonationRequest->hasHeader('x-goog-iam-authority-selector')); - $this->assertEquals('my-selector', $impersonationRequest->getHeaderLine('x-goog-iam-authority-selector')); - } -} diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 989179b2f1..25b724be5a 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, [], '{"locations": [], "encodedLocations": "foo"}'), + new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + ]); + + $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/ServiceAccountCredentialsWithTrustBoundaryTest.php b/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php deleted file mode 100644 index b75e6ccdcf..0000000000 --- a/tests/Credentials/ServiceAccountCredentialsWithTrustBoundaryTest.php +++ /dev/null @@ -1,83 +0,0 @@ - file_get_contents(__DIR__ . '/../fixtures/private.pem'), - 'client_email' => 'test@example.com', - ]; - } - - public function testFetchAuthTokenWithTrustBoundary() - { - $trustBoundaryResponse = new Response(200, [], '{"token": "my-token", "authority_selector": "my-selector"}'); - $accessTokenResponse = new Response(200, [], '{"access_token": "my-access-token", "expires_in": 3600}'); - - $container = []; - $history = Middleware::history($container); - $mock = new MockHandler([$trustBoundaryResponse, $accessTokenResponse]); - $stack = new HandlerStack($mock); - $stack->push($history); - $client = new Client(['handler' => $stack]); - - $creds = new ServiceAccountCredentials('scope', $this->createTestJson(), enableTrustBoundary: true); - $creds->fetchAuthToken(function ($request) use ($client) { - return $client->send($request); - }); - - $this->assertCount(2, $container); - - // First request is for trust boundary - $trustBoundaryRequest = $container[0]['request']; - $this->assertStringContainsString( - '/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', - (string) $trustBoundaryRequest->getUri() - ); - - // Second request is for access token - $accessTokenRequest = $container[1]['request']; - $body = (string) $accessTokenRequest->getBody(); - parse_str($body, $params); - $this->assertArrayHasKey('assertion', $params); - $jwt = $params['assertion']; - list($header, $payload, $signature) = explode('.', $jwt); - $payload = json_decode(base64_decode($payload), true); - - $this->assertArrayHasKey('x-goog-iam-authorization-token', $payload); - $this->assertEquals('my-token', $payload['x-goog-iam-authorization-token']); - $this->assertArrayHasKey('x-goog-iam-authority-selector', $payload); - $this->assertEquals('my-selector', $payload['x-goog-iam-authority-selector']); - } - - public function testFetchAuthTokenWithTrustBoundarySuppressedWithUniverseDomain() - { - $container = []; - $history = Middleware::history($container); - $mock = new MockHandler([]); - $stack = new HandlerStack($mock); - $stack->push($history); - $client = new Client(['handler' => $stack]); - - $json = $this->createTestJson(); - $json['universe_domain'] = 'my-universe.com'; - $creds = new ServiceAccountCredentials('scope', $json, enableTrustBoundary: true); - - $creds->fetchAuthToken(function ($request) use ($client) { - return $client->send($request); - }); - - $this->assertCount(0, $container); - } -} diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index 47e2796ce5..54298a31ff 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 index fb9ec7693e..0394c8b7bc 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -21,21 +21,22 @@ public function setUp(): void public function testBuildTrustBoundaryLookupUrl() { - $url = $this->impl->buildTrustBoundaryLookupUrlPublic('test@example.com'); + $url = $this->impl->buildTrustBoundaryLookupUrl('test@example.com'); $this->assertEquals( - 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/test@example.com/?recursive=true', + 'https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/test@example.com/allowedLocations', $url ); } public function testLookupTrustBoundary() { - $responseBody = '{"token": "my-token", "authority_selector": "my-selector"}'; + $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->lookupTrustBoundaryPublic($handler, 'default'); + $result = $this->impl->lookupTrustBoundary($handler, 'default'); $this->assertEquals(json_decode($responseBody, true), $result); } @@ -45,7 +46,7 @@ public function testLookupTrustBoundary404() new Response(404), ]); $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); - $result = $this->impl->lookupTrustBoundaryPublic($handler, 'default'); + $result = $this->impl->lookupTrustBoundary($handler, 'default'); $this->assertNull($result); } @@ -53,20 +54,21 @@ public function testRefreshTrustBoundaryWithCache() { $cache = new MemoryCacheItemPool(); $this->impl->setCache($cache); - $responseBody = '{"token": "my-token", "authority_selector": "my-selector"}'; + $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->refreshTrustBoundaryPublic($handler, 'default'); + $result1 = $this->impl->getTrustBoundary($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->refreshTrustBoundaryPublic($handler, 'default'); + $result2 = $this->impl->getTrustBoundary($handler, 'default'); $this->assertEquals(json_decode($responseBody, true), $result2); } } @@ -74,9 +76,9 @@ public function testRefreshTrustBoundaryWithCache() class TrustBoundaryTraitImpl { use TrustBoundaryTrait { - buildTrustBoundaryLookupUrl as public buildTrustBoundaryLookupUrlPublic; - lookupTrustBoundary as public lookupTrustBoundaryPublic; - refreshTrustBoundary as public refreshTrustBoundaryPublic; + buildTrustBoundaryLookupUrl as public; + lookupTrustBoundary as public; + getTrustBoundary as public; } private $cache; @@ -99,4 +101,9 @@ public function setCache($cache) { $this->cache = $cache; } + + public function getUniverseDomain() + { + return 'foo.bar'; + } } From cd52df61f829b15d23fe1952e73915d0e36a7e54 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Feb 2026 11:58:09 -0800 Subject: [PATCH 07/12] add tests for GCECredentials --- src/Credentials/ServiceAccountCredentials.php | 2 - .../ServiceAccountJwtAccessCredentials.php | 11 ----- src/TrustBoundaryTrait.php | 6 +-- tests/Credentials/GCECredentialsTest.php | 43 +++++++++++++++++++ ...ersonatedServiceAccountCredentialsTest.php | 2 +- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index bdb78a0268..0ee60b32dc 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -226,8 +226,6 @@ public function fetchAuthToken(?callable $httpHandler = null, array $headers = [ if ($this->useSelfSignedJwt()) { $jwtCreds = $this->createJwtAccessCredentials(); - $jwtCreds->setAdditionalClaims($this->auth->getAdditionalClaims()); - $accessToken = $jwtCreds->fetchAuthToken($httpHandler); if ($lastReceivedToken = $jwtCreds->getLastReceivedToken()) { diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index d37d85875e..50373760b9 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -109,17 +109,6 @@ public function __construct($jsonKey, $scope = null) $this->projectId = $jsonKey['project_id'] ?? null; } - /** - * Sets additional claims to be included in the JWT token - * - * @param array $additionalClaims - * @return void - */ - public function setAdditionalClaims(array $additionalClaims) - { - $this->auth->setAdditionalClaims($additionalClaims); - } - /** * Updates metadata with the authorization token. * diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 1d5b9b8e96..6cfb7c31f5 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -19,7 +19,7 @@ trait TrustBoundaryTrait /** * @return null|array{locations: array, encodedLocations: string} */ - public function getTrustBoundary( + private function getTrustBoundary( ?callable $httpHandler = null, string $serviceAccountEmail = 'default' ): array|null { @@ -28,7 +28,7 @@ public function getTrustBoundary( return null; } - if ($this->getUniverseDomain() !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { + if ($this->getUniverseDomain($httpHandler) !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { // Universe domain is not default, so trust boundary is not supported. return null; } @@ -59,7 +59,7 @@ public function getTrustBoundary( private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail): array|null { $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); - $request = new Request('GET', $url, ['Metadata-Flavor' => 'Google']); + $request = new Request('GET', $url); try { $response = $httpHandler($request); return json_decode((string) $response->getBody(), true); diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index ab7353ce30..9059e6172d 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; @@ -697,4 +698,46 @@ 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, [], '{"locations": [], "encodedLocations": "foo"}'), + 2 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 3 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + }; + }; + + $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}'), + }; + }; + + $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 55f4468fea..c7b65bacff 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -403,7 +403,7 @@ public function testGetIdTokenWithArbitraryCredentials(?string $universeDomain = ->shouldBeCalledOnce() ->willReturn(['access_token' => 'test-access-token']); $credentials->getUniverseDomain() - ->shouldBeCalledTimes(2) + ->shouldBeCalledOnce() ->willReturn($universeDomain ?: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN); $json = [ From ec2bc8a28b8c0975489e351f915a1fa6dc4f3c83 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Feb 2026 12:33:17 -0800 Subject: [PATCH 08/12] fix styles --- src/Credentials/GCECredentials.php | 13 +++++---- .../ImpersonatedServiceAccountCredentials.php | 1 + src/Credentials/ServiceAccountCredentials.php | 1 + src/TrustBoundaryTrait.php | 28 ++++++++++++------- tests/TrustBoundaryTraitTest.php | 4 +-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 073dc351ec..38b8f096d1 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -647,11 +647,14 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - $metadata = $this->updateTrustBoundaryMetadata( - $metadata, - $this->serviceAccountIdentity ?: 'default', - $httpHandler, - ); + if ($this->enableTrustBoundary) { + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->serviceAccountIdentity ?: 'default', + $this->getUniverseDomain($httpHandler), + $httpHandler, + ); + } return parent::updateMetadata($metadata, $authUri, $httpHandler); } diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index ef00e8a43c..35e9074102 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -332,6 +332,7 @@ public function updateMetadata( $metadata = $this->updateTrustBoundaryMetadata( $metadata, $this->sourceCredentials->getClientName(), + $this->getUniverseDomain(), $httpHandler, ); } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 0ee60b32dc..d659fdd5da 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -329,6 +329,7 @@ public function updateMetadata( $metadata = $this->updateTrustBoundaryMetadata( $metadata, $this->auth->getIssuer(), + $this->getUniverseDomain(), $httpHandler, ); diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 6cfb7c31f5..4e694fe7ff 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -20,15 +20,16 @@ trait TrustBoundaryTrait * @return null|array{locations: array, encodedLocations: string} */ private function getTrustBoundary( - ?callable $httpHandler = null, - string $serviceAccountEmail = 'default' + string $universeDomain, + callable $httpHandler, + string $serviceAccountEmail, ): array|null { if (!$this->enableTrustBoundary) { // Only look up the trust boundary if the credentials have been configured to do so return null; } - if ($this->getUniverseDomain($httpHandler) !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { + if ($universeDomain !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN) { // Universe domain is not default, so trust boundary is not supported. return null; } @@ -38,9 +39,6 @@ private function getTrustBoundary( return $cached; } - $httpHandler = $httpHandler - ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); - $trustBoundary = $this->lookupTrustBoundary($httpHandler, $serviceAccountEmail); if (null !== $trustBoundary && !array_key_exists('encodedLocations', $trustBoundary)) { @@ -56,8 +54,10 @@ private function getTrustBoundary( /** * @return null|array{locations: array, encodedLocations: string} */ - private function lookupTrustBoundary(callable $httpHandler, string $serviceAccountEmail): array|null - { + private function lookupTrustBoundary( + callable $httpHandler, + string $serviceAccountEmail + ): array|null { $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); $request = new Request('GET', $url); try { @@ -83,12 +83,20 @@ private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): strin ); } + /** + * @param array $headers + * @return array + */ private function updateTrustBoundaryMetadata( array $headers, string $serviceAccountEmail, - ?callable $httpHandler = null, + string $universeDomain, + ?callable $httpHandler, ): array { - if ($trustBoundaryInfo = $this->getTrustBoundary($httpHandler, $serviceAccountEmail)) { + $httpHandler = $httpHandler + ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + + if ($trustBoundaryInfo = $this->getTrustBoundary($universeDomain, $httpHandler, $serviceAccountEmail)) { $headers['x-allowed-locations'] = $trustBoundaryInfo['encodedLocations']; } return $headers; diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index 0394c8b7bc..663d2853c2 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -62,13 +62,13 @@ public function testRefreshTrustBoundaryWithCache() $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); // First call, should fetch and cache - $result1 = $this->impl->getTrustBoundary($handler, 'default'); + $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($handler, 'default'); + $result2 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default'); $this->assertEquals(json_decode($responseBody, true), $result2); } } From bb18b80832582b91a5977091e1753559c8a63eb0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 9 Feb 2026 06:07:26 -0800 Subject: [PATCH 09/12] ensure trust boundary lookup receives auth token --- src/ApplicationDefaultCredentials.php | 4 +-- src/Credentials/GCECredentials.php | 10 +++--- .../ImpersonatedServiceAccountCredentials.php | 8 +++-- src/Credentials/ServiceAccountCredentials.php | 30 ++++++++++++---- src/CredentialsLoader.php | 2 +- src/TrustBoundaryTrait.php | 35 ++++++++++++++----- tests/Credentials/GCECredentialsTest.php | 6 ++-- ...ersonatedServiceAccountCredentialsTest.php | 2 +- .../ServiceAccountCredentialsTest.php | 2 +- tests/TrustBoundaryTraitTest.php | 8 ++--- 10 files changed, 71 insertions(+), 36 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index e580b84d18..6a0b7a6feb 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. @@ -289,7 +290,6 @@ public static function getIdTokenCredentials( ?callable $httpHandler = null, ?array $cacheConfig = null, ?CacheItemPoolInterface $cache = null, - bool $enableTrustBoundary = false ) { $creds = null; $jsonKey = CredentialsLoader::fromEnv() @@ -315,13 +315,11 @@ public static function getIdTokenCredentials( scope: null, jsonKey: $jsonKey, targetAudience: $targetAudience, - enableTrustBoundary: $enableTrustBoundary ), 'service_account' => new ServiceAccountCredentials( scope: null, jsonKey: $jsonKey, targetAudience: $targetAudience, - enableTrustBoundary: $enableTrustBoundary ), default => throw new InvalidArgumentException('invalid value in the type field') }; diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 38b8f096d1..63883985f3 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -211,7 +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 [optional] Enable the trust boundary lookup. + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. */ public function __construct( ?Iam $iam = null, @@ -647,16 +647,18 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { + $updatedMetadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + if ($this->enableTrustBoundary) { - $metadata = $this->updateTrustBoundaryMetadata( - $metadata, + $updatedMetadata = $this->updateTrustBoundaryMetadata( + $updatedMetadata, $this->serviceAccountIdentity ?: 'default', $this->getUniverseDomain($httpHandler), $httpHandler, ); } - return parent::updateMetadata($metadata, $authUri, $httpHandler); + return $updatedMetadata; } /** diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 35e9074102..c262b48eab 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -323,20 +323,22 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { + $updatedMetadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + if ($this->enableTrustBoundary) { if (!$this->sourceCredentials instanceof ServiceAccountCredentials) { throw new LogicException( 'Trust boundary lookup is only supported for service account credentials' ); } - $metadata = $this->updateTrustBoundaryMetadata( - $metadata, + $updatedMetadata = $this->updateTrustBoundaryMetadata( + $updatedMetadata, $this->sourceCredentials->getClientName(), $this->getUniverseDomain(), $httpHandler, ); } - return parent::updateMetadata($metadata, $authUri, $httpHandler); + return $updatedMetadata; } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index d659fdd5da..d9b1e77336 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -134,7 +134,7 @@ 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 Enable the trust boundary lookup + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. */ public function __construct( $scope, @@ -326,18 +326,34 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - $metadata = $this->updateTrustBoundaryMetadata( - $metadata, + // scope exists. use oauth implementation + $updatedMetadata = $this->useSelfSignedJwt() + ? $this->updateMetadataSelfSignedJwt($metadata, $authUri, $httpHandler) + : parent::updateMetadata($metadata, $authUri, $httpHandler); + + $updatedMetadata = $this->updateTrustBoundaryMetadata( + $updatedMetadata, $this->auth->getIssuer(), $this->getUniverseDomain(), $httpHandler, ); - // scope exists. use oauth implementation - if (!$this->useSelfSignedJwt()) { - return parent::updateMetadata($metadata, $authUri, $httpHandler); - } + return $updatedMetadata; + } + /** + * 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(); // Prefer user-provided "scope" to "audience" diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 4a0fbc1647..d90c86ef08 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -152,7 +152,7 @@ public static function fromWellKnownFile() * @param string|string[] $scope * @param array $jsonKey * @param string|string[] $defaultScope - * @param bool $enableTrustBoundary Enable the trust boundary lookup + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials */ public static function makeCredentials( diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 4e694fe7ff..f114ebaa98 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -17,12 +17,14 @@ trait TrustBoundaryTrait private bool $enableTrustBoundary = false; /** + * @param array $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 @@ -39,7 +41,16 @@ private function getTrustBoundary( return $cached; } - $trustBoundary = $this->lookupTrustBoundary($httpHandler, $serviceAccountEmail); + 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\''); @@ -52,24 +63,23 @@ private function getTrustBoundary( } /** + * @param array $authHeader * @return null|array{locations: array, encodedLocations: string} */ private function lookupTrustBoundary( callable $httpHandler, - string $serviceAccountEmail + 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 404s here. This is because we reasonably expect 404s - // to be returned from the metadata server for service accounts - // that do not exist or do not have the required permissions. - if ($e->getResponse()->getStatusCode() !== 404) { - throw $e; - } + // We swallow all errors here - a failed trust boundary lookup + // should not disrupt client authentication. } return null; } @@ -96,7 +106,14 @@ private function updateTrustBoundaryMetadata( $httpHandler = $httpHandler ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); - if ($trustBoundaryInfo = $this->getTrustBoundary($universeDomain, $httpHandler, $serviceAccountEmail)) { + $trustBoundaryInfo = $this->getTrustBoundary( + $universeDomain, + $httpHandler, + $serviceAccountEmail, + $headers + ); + + if ($trustBoundaryInfo) { $headers['x-allowed-locations'] = $trustBoundaryInfo['encodedLocations']; } return $headers; diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 9059e6172d..40cc0c7e5b 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -704,9 +704,9 @@ public function testUpdateMetadataWithTrustBoundary() $timesCalled = 0; $httpHandler = function () use (&$timesCalled) { return match (++$timesCalled) { - 1 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), - 2 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), - 3 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 1 => new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + 2 => new Response(200, [], '{"access_token": "abc", "expires_in": 57}'), + 3 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), }; }; diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index c7b65bacff..bf00413929 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -554,9 +554,9 @@ public function provideScopePrecedence() public function testUpdateMetadataWithTrustBoundary() { $httpHandler = getHandler([ - new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), 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 = [ diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 25b724be5a..d46f457e75 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -428,8 +428,8 @@ public function testGetQuotaProject() public function testUpdateMetadataWithTrustBoundary() { $httpHandler = getHandler([ - new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), new Response(200, [], '{"access_token": "source-token", "expires_in": 3600}'), + new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), ]); $jsonKey = [ diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index 663d2853c2..9f4035b319 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -36,7 +36,7 @@ public function testLookupTrustBoundary() new Response(200, [], $responseBody), ]); $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); - $result = $this->impl->lookupTrustBoundary($handler, 'default'); + $result = $this->impl->lookupTrustBoundary($handler, 'default', []); $this->assertEquals(json_decode($responseBody, true), $result); } @@ -46,7 +46,7 @@ public function testLookupTrustBoundary404() new Response(404), ]); $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); - $result = $this->impl->lookupTrustBoundary($handler, 'default'); + $result = $this->impl->lookupTrustBoundary($handler, 'default', []); $this->assertNull($result); } @@ -62,13 +62,13 @@ public function testRefreshTrustBoundaryWithCache() $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); // First call, should fetch and cache - $result1 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default'); + $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'); + $result2 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default', []); $this->assertEquals(json_decode($responseBody, true), $result2); } } From a42532820c8341a126373da9bbaf1e3a1263f918 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 10 Feb 2026 02:19:15 -0800 Subject: [PATCH 10/12] ensure enableTrustBoundary is passed to GCECredentials --- src/ApplicationDefaultCredentials.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 6a0b7a6feb..78b266e345 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -204,7 +204,12 @@ public static function getCredentials( } 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 } @@ -324,7 +329,7 @@ public static function getIdTokenCredentials( 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 } From a3559bcc804493feb0be6f80637b7d12b80f2358 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 10 Feb 2026 02:44:55 -0800 Subject: [PATCH 11/12] fix default client name for GCE --- src/Credentials/GCECredentials.php | 2 +- tests/Credentials/GCECredentialsTest.php | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 63883985f3..7879535a03 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -652,7 +652,7 @@ public function updateMetadata( if ($this->enableTrustBoundary) { $updatedMetadata = $this->updateTrustBoundaryMetadata( $updatedMetadata, - $this->serviceAccountIdentity ?: 'default', + $this->getClientName($httpHandler), $this->getUniverseDomain($httpHandler), $httpHandler, ); diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 40cc0c7e5b..8623813cca 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -206,6 +206,9 @@ public function testOnAppEngineFlexIsFalseByDefault() $this->assertFalse(GCECredentials::onAppEngineFlexible()); } + /** + * @runInSeparateProcess + */ public function testOnAppEngineFlexIsTrueWhenGaeInstanceHasAefPrefix() { putenv('GAE_INSTANCE=aef-default-20180313t154438'); @@ -301,6 +304,7 @@ public function testSettingBothScopeAndTargetAudienceThrowsException() /** * @dataProvider scopes + * @runInSeparateProcess */ public function testFetchAuthTokenCustomScope($scope, $expected) { @@ -388,6 +392,9 @@ public function testGetClientNameShouldBeEmptyIfNotOnGCE() $this->assertEquals('', $creds->getClientName($httpHandler)); } + /** + * @runInSeparateProcess + */ public function testSignBlob() { $expectedEmail = 'test@test.com'; @@ -419,6 +426,9 @@ public function testSignBlob() $signature = $creds->signBlob($stringToSign); } + /** + * @runInSeparateProcess + */ public function testSignBlobWithLastReceivedAccessToken() { $expectedEmail = 'test@test.com'; @@ -460,6 +470,9 @@ public function testSignBlobWithLastReceivedAccessToken() $signature = $creds->signBlob($stringToSign); } + /** + * @runInSeparateProcess + */ public function testSignBlobWithUniverseDomain() { $token = [ @@ -498,6 +511,9 @@ public function testSignBlobWithUniverseDomain() $this->assertEquals('abc123', $signature); } + /** + * @runInSeparateProcess + */ public function testGetProjectId() { $expected = 'foobar'; @@ -519,6 +535,9 @@ public function testGetProjectId() $this->assertEquals($expected, $creds->getProjectId()); } + /** + * @runInSeparateProcess + */ public function testGetProjectIdShouldBeEmptyIfNotOnGCE() { // simulate retry attempts by returning multiple 500s @@ -706,7 +725,8 @@ public function testUpdateMetadataWithTrustBoundary() 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, [], '{"locations": [], "encodedLocations": "foo"}'), + 3 => new Response(200, [], '{}'), + 4 => new Response(200, [], '{"locations": [], "encodedLocations": "foo"}'), }; }; @@ -728,6 +748,7 @@ public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain( 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, [], '{}'), }; }; From 94e041678076efce390e83950b0f3ddc64573765 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 10 Feb 2026 04:12:47 -0800 Subject: [PATCH 12/12] add integration test for trust boundary headers --- src/Credentials/GCECredentials.php | 8 +-- .../ImpersonatedServiceAccountCredentials.php | 23 +++----- src/Credentials/ServiceAccountCredentials.php | 21 ++++---- src/TrustBoundaryTrait.php | 1 + tests/ApplicationDefaultCredentialsTest.php | 52 ++++++++++++++++++- 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 7879535a03..693c66f05d 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -647,18 +647,18 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - $updatedMetadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + $metadata = parent::updateMetadata($metadata, $authUri, $httpHandler); if ($this->enableTrustBoundary) { - $updatedMetadata = $this->updateTrustBoundaryMetadata( - $updatedMetadata, + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, $this->getClientName($httpHandler), $this->getUniverseDomain($httpHandler), $httpHandler, ); } - return $updatedMetadata; + return $metadata; } /** diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index c262b48eab..680ba15701 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -323,22 +323,15 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - $updatedMetadata = parent::updateMetadata($metadata, $authUri, $httpHandler); + $metatadata = parent::updateMetadata($metadata, $authUri, $httpHandler); - if ($this->enableTrustBoundary) { - if (!$this->sourceCredentials instanceof ServiceAccountCredentials) { - throw new LogicException( - 'Trust boundary lookup is only supported for service account credentials' - ); - } - $updatedMetadata = $this->updateTrustBoundaryMetadata( - $updatedMetadata, - $this->sourceCredentials->getClientName(), - $this->getUniverseDomain(), - $httpHandler, - ); - } + $metatadata = $this->updateTrustBoundaryMetadata( + $metatadata, + $this->impersonatedServiceAccountName, + $this->getUniverseDomain(), + $httpHandler, + ); - return $updatedMetadata; + return $metatadata; } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index d9b1e77336..da1013bb1b 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -326,19 +326,18 @@ public function updateMetadata( $authUri = null, ?callable $httpHandler = null ) { - // scope exists. use oauth implementation - $updatedMetadata = $this->useSelfSignedJwt() + $metadata = $this->useSelfSignedJwt() ? $this->updateMetadataSelfSignedJwt($metadata, $authUri, $httpHandler) : parent::updateMetadata($metadata, $authUri, $httpHandler); - $updatedMetadata = $this->updateTrustBoundaryMetadata( - $updatedMetadata, + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, $this->auth->getIssuer(), $this->getUniverseDomain(), $httpHandler, ); - return $updatedMetadata; + return $metadata; } /** @@ -356,17 +355,19 @@ private function updateMetadataSelfSignedJwt( ) { $jwtCreds = $this->createJwtAccessCredentials(); - // Prefer user-provided "scope" to "audience" - $updatedMetadata = $this->auth->getScope() - ? $jwtCreds->updateMetadata($metadata, null, $httpHandler) - : $jwtCreds->updateMetadata($metadata, $authUri, $httpHandler); + $metadata = $jwtCreds->updateMetadata( + $metadata, + // Prefer user-provided "scope" to "audience" + $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/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index f114ebaa98..8c290d23bb 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -116,6 +116,7 @@ private function updateTrustBoundaryMetadata( if ($trustBoundaryInfo) { $headers['x-allowed-locations'] = $trustBoundaryInfo['encodedLocations']; } + return $headers; } } diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index d973a16893..f779fd20b9 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')); } }