From 6413043e74e36067b0e7ef8aec463d1a49ee461a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 30 Jan 2026 08:36:37 -0800 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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')); } } From 77710aad6fce148b8c8f8330fe5547d429f15524 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Feb 2026 09:48:21 -0800 Subject: [PATCH 13/21] fix fixtures path in tests --- .../Credentials/ImpersonatedServiceAccountCredentialsTest.php | 4 ++-- tests/Credentials/ServiceAccountCredentialsTest.php | 4 ++-- tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index bf00413929..8e592e08a3 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -563,7 +563,7 @@ public function testUpdateMetadataWithTrustBoundary() 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', 'source_credentials' => [ 'type' => 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), 'client_email' => 'test@example.com', ], ]; @@ -589,7 +589,7 @@ public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain( 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1', 'source_credentials' => [ 'type' => 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), 'client_email' => 'test@example.com', 'universe_domain' => 'foo.com' ], diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index f0841d5a68..275acab351 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -434,7 +434,7 @@ public function testUpdateMetadataWithTrustBoundary() $jsonKey = [ 'type' => 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), 'client_email' => 'test@example.com', ]; $serviceAccountCreds = new ServiceAccountCredentials( @@ -453,7 +453,7 @@ public function testUpdateMetadataWithTrustBoundarySuppressedWithUniverseDomain( { $jsonKey = [ 'type' => 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), 'client_email' => 'test@example.com', 'universe_domain' => 'foo.com', ]; diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index 62063ff04b..57c0bf5ce1 100644 --- a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php +++ b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -561,7 +561,7 @@ public function testUpdateMetadataWithTrustBoundary() $jsonKey = [ 'type' => 'service_account', - 'private_key' => file_get_contents(__DIR__ . '/../fixtures/private.pem'), + 'private_key' => file_get_contents(__DIR__ . '/../fixtures/fixtures1/private.pem'), 'client_email' => 'test@example.com', ]; $serviceAccountCreds = new ServiceAccountCredentials( From 21e7bd14d426de77bc11c4b2e8412db38cdd8285 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Feb 2026 12:02:54 -0800 Subject: [PATCH 14/21] add trust boundary for ExternalAccountCredentials --- .../ExternalAccountCredentials.php | 92 +++++++++++++++-- src/Credentials/GCECredentials.php | 4 +- .../ImpersonatedServiceAccountCredentials.php | 4 +- src/Credentials/ServiceAccountCredentials.php | 4 +- src/CredentialsLoader.php | 2 +- src/TrustBoundaryTrait.php | 99 ++++++++++++------- tests/ApplicationDefaultCredentialsTest.php | 4 +- tests/TrustBoundaryTraitTest.php | 9 +- 8 files changed, 163 insertions(+), 55 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index afaf1ee3f8..175352d87c 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -30,10 +30,12 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\OAuth2; use Google\Auth\ProjectIdProviderInterface; +use Google\Auth\TrustBoundaryTrait; use Google\Auth\UpdateMetadataInterface; use Google\Auth\UpdateMetadataTrait; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; +use LogicException; /** * **IMPORTANT**: @@ -51,7 +53,12 @@ class ExternalAccountCredentials implements GetUniverseDomainInterface, ProjectIdProviderInterface { - use UpdateMetadataTrait; + use UpdateMetadataTrait { + updateMetadata as traitUpdateMetadata; + } + use TrustBoundaryTrait { + buildTrustBoundaryLookupUrl as traitBuildTrustBoundaryLookupUrl; + } private const EXTERNAL_ACCOUNT_TYPE = 'external_account'; private const CLOUD_RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.UNIVERSE_DOMAIN/v1/projects/%s'; @@ -69,10 +76,12 @@ class ExternalAccountCredentials implements * @param string|string[] $scope The scope of the access request, expressed either as an array * or as a space-delimited string. * @param array $jsonKey JSON credentials as an associative array. + * @param bool $enableTrustBoundary Lookup and include the trust boundary header. */ public function __construct( $scope, - array $jsonKey + array $jsonKey, + bool $enableTrustBoundary = false ) { if (!array_key_exists('type', $jsonKey)) { throw new InvalidArgumentException('json key is missing the type field'); @@ -114,6 +123,7 @@ public function __construct( $this->quotaProject = $jsonKey['quota_project_id'] ?? null; $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null; $this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + $this->enableTrustBoundary = $enableTrustBoundary; $this->auth = new OAuth2([ 'tokenCredentialUri' => $jsonKey['token_url'], @@ -200,11 +210,8 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr } if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) { - // Parse email from URL. The formal looks as follows: - // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken - $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; - if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { - $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email']; + if ($email = self::getServiceAccountImpersonationEmail($serviceAccountImpersonationUrl)) { + $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $email; } } @@ -220,6 +227,18 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr throw new InvalidArgumentException('Unable to determine credential source from json key.'); } + private static function getServiceAccountImpersonationEmail(string $serviceAccountImpersonationUrl): string|null + { + // Parse email from URL. The formal looks as follows: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken + $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; + if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { + return $matches['email']; + } + + return null; + } + /** * @param string $stsToken * @param callable|null $httpHandler @@ -290,6 +309,37 @@ public function fetchAuthToken(?callable $httpHandler = null, array $headers = [ return $stsToken; } + /** + * 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->traitUpdateMetadata($metadata, $authUri, $httpHandler); + + if ($this->enableTrustBoundary) { + $clientName = $this->serviceAccountImpersonationUrl + ? self::getServiceAccountImpersonationEmail($this->serviceAccountImpersonationUrl) + : ''; // @TODO: What do we do when this is empty? + + $metadata = $this->updateTrustBoundaryMetadata( + $metadata, + $this->buildTrustBoundaryLookupUrl(), + $this->getUniverseDomain($httpHandler), + $httpHandler, + ); + } + + return $metadata; + } + /** * Get the cache token key for the credentials. * The cache token key format depends on the type of source @@ -391,4 +441,32 @@ private function isWorkforcePool(): bool $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#'; return preg_match($regex, $this->auth->getAudience()) === 1; } + + /** + * Builds and returns the URL for the trust boundary lookup API. + */ + private function buildTrustBoundaryLookupUrl(): string + { + // Try to parse as a workload identity pool. + // Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID + $regex = '/projects\/([^\/]+)\/locations\/global\/workloadIdentityPools\/([^\/]+)/'; + if (preg_match($regex, $this->auth->getAudience(), $matches)) { + [$_, $projectNumber, $poolId] = $matches; + + return $this->traitBuildTrustBoundaryLookupUrl( + poolId: $poolId, + projectNumber: $projectNumber, + ); + } + + // If that fails, try to parse as a workforce pool. + // Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID + if (preg_match('/locations\/[^\/]+\/workforcePools\/([^\/]+)/', $this->auth->getAudience(), $matches)) { + return $this->traitBuildTrustBoundaryLookupUrl( + poolId: $matches[1], + ); + } + + throw new LogicException("Invalid audience format."); + } } diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 693c66f05d..25a937cc3e 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -652,7 +652,9 @@ public function updateMetadata( if ($this->enableTrustBoundary) { $metadata = $this->updateTrustBoundaryMetadata( $metadata, - $this->getClientName($httpHandler), + $this->buildTrustBoundaryLookupUrl( + serviceAccountEmail: $this->getClientName($httpHandler) + ), $this->getUniverseDomain($httpHandler), $httpHandler, ); diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 680ba15701..5519e36524 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -327,7 +327,9 @@ public function updateMetadata( $metatadata = $this->updateTrustBoundaryMetadata( $metatadata, - $this->impersonatedServiceAccountName, + $this->buildTrustBoundaryLookupUrl( + serviceAccountEmail: $this->impersonatedServiceAccountName + ), $this->getUniverseDomain(), $httpHandler, ); diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index da1013bb1b..7def08f074 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -332,7 +332,9 @@ public function updateMetadata( $metadata = $this->updateTrustBoundaryMetadata( $metadata, - $this->auth->getIssuer(), + $this->buildTrustBoundaryLookupUrl( + serviceAccountEmail: $this->auth->getIssuer() + ), $this->getUniverseDomain(), $httpHandler, ); diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index d90c86ef08..bce4a36f8a 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -186,7 +186,7 @@ public static function makeCredentials( if ($jsonKey['type'] == 'external_account') { $anyScope = $scope ?: $defaultScope; - return new ExternalAccountCredentials($anyScope, $jsonKey); + return new ExternalAccountCredentials($anyScope, $jsonKey, $enableTrustBoundary); } throw new \InvalidArgumentException('invalid value in the type field'); diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 8c290d23bb..7376e30e56 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -6,6 +6,7 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Request; +use InvalidArgumentException; /** * @internal @@ -23,7 +24,7 @@ trait TrustBoundaryTrait private function getTrustBoundary( string $universeDomain, callable $httpHandler, - string $serviceAccountEmail, + string $trustBoundaryUrl, array $headers, ): array|null { if (!$this->enableTrustBoundary) { @@ -48,7 +49,7 @@ private function getTrustBoundary( $trustBoundary = $this->lookupTrustBoundary( $httpHandler, - $serviceAccountEmail, + $trustBoundaryUrl, $headers['authorization'] ); @@ -62,44 +63,13 @@ private function getTrustBoundary( return $trustBoundary; } - /** - * @param array $authHeader - * @return null|array{locations: array, encodedLocations: string} - */ - private function lookupTrustBoundary( - callable $httpHandler, - string $serviceAccountEmail, - array $authHeader - ): array|null { - $url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail); - $request = new Request('GET', $url); - $request = $request->withHeader('authorization', $authHeader); - try { - $response = $httpHandler($request); - return json_decode((string) $response->getBody(), true); - } catch (ClientException $e) { - // We swallow all errors here - a failed trust boundary lookup - // should not disrupt client authentication. - } - return null; - } - - private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): string - { - return sprintf( - 'https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s/allowedLocations', - $this->getUniverseDomain(), - $serviceAccountEmail - ); - } - /** * @param array $headers * @return array */ private function updateTrustBoundaryMetadata( array $headers, - string $serviceAccountEmail, + string $trustBoundaryUrl, string $universeDomain, ?callable $httpHandler, ): array { @@ -109,7 +79,7 @@ private function updateTrustBoundaryMetadata( $trustBoundaryInfo = $this->getTrustBoundary( $universeDomain, $httpHandler, - $serviceAccountEmail, + $trustBoundaryUrl, $headers ); @@ -119,4 +89,63 @@ private function updateTrustBoundaryMetadata( return $headers; } + + /** + * Return the trust boundary lookup URL. + */ + private function buildTrustBoundaryLookupUrl( + ?string $serviceAccountEmail = null, + ?string $poolId = null, + ?string $projectNumber = null, + ): string { + $baseUrl = 'https://iamcredentials.googleapis.com/v1'; + if ($serviceAccountEmail) { + if (is_null($projectNumber) && is_null($poolId)) { + return sprintf( + '%s/projects/-/serviceAccounts/%s/allowedLocations', + $baseUrl, + $serviceAccountEmail + ); + } + } elseif ($poolId) { + if (is_null($projectNumber)) { + // Workforce Identity Pools + return sprintf( + '%s/locations/global/workforcePools/%s/allowedLocations', + $baseUrl, + $poolId + ); + } + // Workload Identity Pools + return sprintf( + '%s/projects/%s/locations/global/workloadIdentityPools/%s/allowedLocations', + $baseUrl, + $projectNumber, + $poolId + ); + } + + throw new InvalidArgumentException('Must supply $serviceAccountEmail, $poolId, or both $poolId and $projectId'); + } + + /** + * @param array $authHeader + * @return null|array{locations: array, encodedLocations: string} + */ + private function lookupTrustBoundary( + callable $httpHandler, + string $trustBoundaryUrl, + array $authHeader + ): array|null { + $request = new Request('GET', $trustBoundaryUrl); + $request = $request->withHeader('authorization', $authHeader); + try { + $response = $httpHandler($request); + return json_decode((string) $response->getBody(), true); + } catch (ClientException $e) { + // We swallow all errors here - a failed trust boundary lookup + // should not disrupt client authentication. + } + return null; + } } diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 2b854603eb..4a26e32484 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -909,8 +909,8 @@ 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' + 'This test requires RUN_TRUST_BOUNDARY_TESTS=true and a set of credentials with ' . + 'trust boundaries enabled' ); } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index 9f4035b319..daadfa6a11 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -21,9 +21,9 @@ public function setUp(): void public function testBuildTrustBoundaryLookupUrl() { - $url = $this->impl->buildTrustBoundaryLookupUrl('test@example.com'); + $url = $this->impl->buildTrustBoundaryLookupUrl(serviceAccountEmail: 'test@example.com'); $this->assertEquals( - 'https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/test@example.com/allowedLocations', + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com/allowedLocations', $url ); } @@ -101,9 +101,4 @@ public function setCache($cache) { $this->cache = $cache; } - - public function getUniverseDomain() - { - return 'foo.bar'; - } } From 5155556566b54453146aad96a367b7b48fbe5e4e Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Feb 2026 13:40:36 -0800 Subject: [PATCH 15/21] fix styles and tests --- src/Credentials/ExternalAccountCredentials.php | 2 +- tests/FetchAuthTokenCacheTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 175352d87c..4faa4bd6dd 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -332,7 +332,7 @@ public function updateMetadata( $metadata = $this->updateTrustBoundaryMetadata( $metadata, $this->buildTrustBoundaryLookupUrl(), - $this->getUniverseDomain($httpHandler), + $this->getUniverseDomain(), $httpHandler, ); } diff --git a/tests/FetchAuthTokenCacheTest.php b/tests/FetchAuthTokenCacheTest.php index 15ce22a5e8..caf5a8ba9a 100644 --- a/tests/FetchAuthTokenCacheTest.php +++ b/tests/FetchAuthTokenCacheTest.php @@ -282,7 +282,7 @@ public function testUpdateMetadataWithGceCredForIdToken() $this->assertEquals($metadata, $metadata2); // Ensure token for different URI is NOT cached - $metadata3 = $cachedFetcher->updateMetadata([], 'http://test-auth-uri-2'); + $metadata3 = $cachedFetcher->updateMetadata([], 'http://test-auth-uri-2', getHandler([new Response(200)])); $this->assertNotEquals($metadata, $metadata3); } From f564f87fee24e4ce5a91c9587cbbd22189c60c2b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Feb 2026 14:15:23 -0800 Subject: [PATCH 16/21] fix more styles and tests --- src/Credentials/ExternalAccountCredentials.php | 2 +- tests/TrustBoundaryTraitTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 4faa4bd6dd..1ae7df548c 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -467,6 +467,6 @@ private function buildTrustBoundaryLookupUrl(): string ); } - throw new LogicException("Invalid audience format."); + throw new LogicException('Invalid audience format'); } } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index daadfa6a11..b3666025c7 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', ['Bearer xyz']); $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', ['Bearer xyz']); $this->assertNull($result); } From 4999bdc1268daabfaadc3b54acfca16778c1a712 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Mar 2026 12:06:50 -0800 Subject: [PATCH 17/21] Add 6-hour TTL for location cache, add test for universe domain skip --- src/CacheTrait.php | 5 ++- src/TrustBoundaryTrait.php | 3 +- tests/TrustBoundaryTraitTest.php | 71 ++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/CacheTrait.php b/src/CacheTrait.php index 49aa34649f..a991c57713 100644 --- a/src/CacheTrait.php +++ b/src/CacheTrait.php @@ -66,9 +66,10 @@ private function getCachedValue($k) * * @param mixed $k * @param mixed $v + * @param int|null $lifetime * @return mixed */ - private function setCachedValue($k, $v) + private function setCachedValue($k, $v, ?int $lifetime = null) { if (is_null($this->cache)) { return null; @@ -81,7 +82,7 @@ private function setCachedValue($k, $v) $cacheItem = $this->cache->getItem($key); $cacheItem->set($v); - $cacheItem->expiresAfter($this->cacheConfig['lifetime']); + $cacheItem->expiresAfter($lifetime ?? $this->cacheConfig['lifetime']); return $this->cache->save($cacheItem); } diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 7376e30e56..d8db25ffe2 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -58,7 +58,8 @@ private function getTrustBoundary( } // Save to cache - $this->setCachedValue($this->getCacheKey() . ':trustboundary', $trustBoundary); + $tbLifetime = 6 * 60 * 60; // 6-hour cache TTL + $this->setCachedValue($this->getCacheKey() . ':trustboundary', $trustBoundary, $tbLifetime); return $trustBoundary; } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index b3666025c7..5d0abadacc 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -3,15 +3,22 @@ namespace Google\Auth\Tests; use Google\Auth\Cache\MemoryCacheItemPool; +use Google\Auth\GetUniverseDomainInterface; use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\TrustBoundaryTrait; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; class TrustBoundaryTraitTest extends TestCase { + use ProphecyTrait; + private $impl; public function setUp(): void @@ -55,22 +62,79 @@ public function testRefreshTrustBoundaryWithCache() $cache = new MemoryCacheItemPool(); $this->impl->setCache($cache); $responseBody = - '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "enodedLocations": ""0xA30"}'; + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "encodedLocations": "0xA30"}'; $mock = new MockHandler([ new Response(200, [], $responseBody), ]); $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); // First call, should fetch and cache - $result1 = $this->impl->getTrustBoundary('universe.domain', $handler, 'default', []); + $result1 = $this->impl->getTrustBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); $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( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + [] + ); $this->assertEquals(json_decode($responseBody, true), $result2); } + + public function testCacheLifetime() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cacheItem->set(Argument::any())->shouldBeCalledOnce(); + $cacheItem->expiresAfter(6 * 60 * 60)->shouldBeCalledOnce(); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem(Argument::type('string')) + ->shouldBeCalledTimes(2) + ->willReturn($cacheItem->reveal()); + $cache->save($cacheItem->reveal())->shouldBeCalledOnce(); + + $this->impl->setCache($cache->reveal()); + + $responseBody = + '{"locations": ["us-central1", "us-east1", "europe-west1", "asia-east1"], "encodedLocations": "0xA30"}'; + $mock = new MockHandler([ + new Response(200, [], $responseBody), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNotNull($result1); + $this->assertEquals(json_decode($responseBody, true), $result1); + } + + public function testSkipLookupOutsideDefaultUniverseDomain() + { + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary( + 'universe.domain', + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } } class TrustBoundaryTraitImpl @@ -90,6 +154,7 @@ public function __construct(array $config = []) 'prefix' => '', 'lifetime' => 1000, ]; + $this->enableTrustBoundary = true; } public function getCacheKey() From 9937e3dc1371e1d78dcb9d850948bcf83c30efd1 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Mar 2026 14:33:27 -0800 Subject: [PATCH 18/21] test for the lookup swallowing errors --- src/TrustBoundaryTrait.php | 4 ++-- tests/TrustBoundaryTraitTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index d8db25ffe2..fb95d7d10f 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -4,7 +4,7 @@ use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; -use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; @@ -143,7 +143,7 @@ private function lookupTrustBoundary( try { $response = $httpHandler($request); return json_decode((string) $response->getBody(), true); - } catch (ClientException $e) { + } catch (RequestException $e) { // We swallow all errors here - a failed trust boundary lookup // should not disrupt client authentication. } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index 5d0abadacc..b3d39588f6 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -7,7 +7,9 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\TrustBoundaryTrait; use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -135,6 +137,28 @@ public function testSkipLookupOutsideDefaultUniverseDomain() $this->assertNull($result1); } + + public function testLookupIsFailOpen() + { + $mock = new MockHandler([ + new RequestException('Error Communicating with Server', new Request('GET', 'test')) + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + $this->assertNull($mock->getLastRequest()); + + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + // Ensure the request was made and the error was swallowed + $this->assertNotNull($mock->getLastRequest()); + $this->assertNull($result1); + } } class TrustBoundaryTraitImpl From e25b47fbbb2c6b8f6adcbde72a6809cbbf6738ec Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Mar 2026 14:44:53 -0800 Subject: [PATCH 19/21] fix cache mocks in tests --- tests/TrustBoundaryTraitTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index b3d39588f6..cee4cec1ca 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -95,14 +95,14 @@ public function testCacheLifetime() { $cacheItem = $this->prophesize(CacheItemInterface::class); $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); - $cacheItem->set(Argument::any())->shouldBeCalledOnce(); - $cacheItem->expiresAfter(6 * 60 * 60)->shouldBeCalledOnce(); + $cacheItem->set(Argument::any())->shouldBeCalledOnce()->willReturn($cacheItem->reveal()); + $cacheItem->expiresAfter(6 * 60 * 60)->shouldBeCalledOnce()->willReturn($cacheItem->reveal()); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem(Argument::type('string')) ->shouldBeCalledTimes(2) ->willReturn($cacheItem->reveal()); - $cache->save($cacheItem->reveal())->shouldBeCalledOnce(); + $cache->save($cacheItem->reveal())->shouldBeCalledOnce()->willReturn(true); $this->impl->setCache($cache->reveal()); From 211488d6ee6d8c2de10842f536b09cf7520c8af4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 6 Mar 2026 15:52:28 -0800 Subject: [PATCH 20/21] add cooldown --- src/TrustBoundaryTrait.php | 33 +++++++++- tests/TrustBoundaryTraitTest.php | 104 ++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index fb95d7d10f..0cf9147eac 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -47,13 +47,23 @@ private function getTrustBoundary( return null; } + if ($this->getCachedValue($this->getCacheKey() . ':trustboundary:cooldown')) { + // We are in a cooldown period, wait until it's over + return null; + } + $trustBoundary = $this->lookupTrustBoundary( $httpHandler, $trustBoundaryUrl, $headers['authorization'] ); - if (null !== $trustBoundary && !array_key_exists('encodedLocations', $trustBoundary)) { + if (null === $trustBoundary) { + // Do not save null trust boundary to cache. Instead, fail open and try again on a subsequent request. + return null; + } + + if (!array_key_exists('encodedLocations', $trustBoundary)) { throw new \LogicException('Trust boundary lookup failed to return \'encodedLocations\''); } @@ -146,7 +156,28 @@ private function lookupTrustBoundary( } catch (RequestException $e) { // We swallow all errors here - a failed trust boundary lookup // should not disrupt client authentication. + $this->initiateCooldown(); } return null; } + + private function initiateCooldown() + { + $cooldownKey = $this->getCacheKey() . ':trustboundary:cooldown'; + $attempt = $this->getCachedValue($cooldownKey . ':attempt') ?? 0; + + $cooldownBackoff = 15 * 60; // 15 minutes + $cooldownMax = 6 * 60 * 60; // 6 hours + $cooldownPeriod = min(++$attempt * $cooldownBackoff, $cooldownMax); + $this->setCachedValue( + $cooldownKey, + true, + $cooldownPeriod + ); + $this->setCachedValue( + $cooldownKey . ':attempt', + $attempt, + $cooldownPeriod * 2 + ); + } } diff --git a/tests/TrustBoundaryTraitTest.php b/tests/TrustBoundaryTraitTest.php index cee4cec1ca..dbe0aa7ae4 100644 --- a/tests/TrustBoundaryTraitTest.php +++ b/tests/TrustBoundaryTraitTest.php @@ -99,11 +99,17 @@ public function testCacheLifetime() $cacheItem->expiresAfter(6 * 60 * 60)->shouldBeCalledOnce()->willReturn($cacheItem->reveal()); $cache = $this->prophesize(CacheItemPoolInterface::class); - $cache->getItem(Argument::type('string')) + $cache->getItem('testkeytrustboundary') ->shouldBeCalledTimes(2) ->willReturn($cacheItem->reveal()); $cache->save($cacheItem->reveal())->shouldBeCalledOnce()->willReturn(true); + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeytrustboundarycooldown') + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItem->reveal()); + $this->impl->setCache($cache->reveal()); $responseBody = @@ -159,6 +165,102 @@ public function testLookupIsFailOpen() $this->assertNotNull($mock->getLastRequest()); $this->assertNull($result1); } + + public function testSkipLookupDuringCooldown() + { + $cache = $this->prophesize(CacheItemPoolInterface::class); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeytrustboundary') + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(true); + $cooldownCacheItem->get()->shouldBeCalledOnce()->willReturn(true); + + $cache->getItem('testkeytrustboundarycooldown') + ->shouldBeCalledOnce() + ->willReturn($cooldownCacheItem->reveal()); + + $this->impl->setCache($cache->reveal()); + + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + fn () => throw new \Exception('Should not be called'), + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } + + public function provideCooldown() + { + $fifteenMinutes = 15 * 60; // cooldown increment + $sixHours = 6 * 60 * 60; // max cooldown + return [ + [0, $fifteenMinutes], + [1, $fifteenMinutes * 2], + [1000, $sixHours], + ]; + } + + /** + * @dataProvider provideCooldown + */ + public function testCooldown(int $attempt, int $expectedExpiry) + { + $cache = $this->prophesize(CacheItemPoolInterface::class); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cache->getItem('testkeytrustboundary') + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + + $cooldownCacheItem = $this->prophesize(CacheItemInterface::class); + $cooldownCacheItem->isHit()->shouldBeCalledOnce()->willReturn(false); + $cooldownCacheItem->set(true)->shouldBeCalledOnce()->willReturn($cooldownCacheItem->reveal()); + $cooldownCacheItem->expiresAfter($expectedExpiry)->shouldBeCalledOnce()->willReturn($cooldownCacheItem->reveal()); + $cache->getItem('testkeytrustboundarycooldown') + ->shouldBeCalledTimes(2) + ->willReturn($cooldownCacheItem->reveal()); + $cache->save($cooldownCacheItem->reveal())->shouldBeCalledOnce()->willReturn(true); + + $cooldownCacheItemAttempt = $this->prophesize(CacheItemInterface::class); + if (0 === $attempt) { + $cooldownCacheItemAttempt->isHit()->shouldBeCalledOnce()->willReturn(false); + } else { + $cooldownCacheItemAttempt->isHit()->shouldBeCalledOnce()->willReturn(true); + $cooldownCacheItemAttempt->get()->shouldBeCalledOnce()->willReturn($attempt); + } + $cooldownCacheItemAttempt->set($attempt + 1)->shouldBeCalledOnce()->willReturn($cooldownCacheItemAttempt->reveal()); + $cooldownCacheItemAttempt->expiresAfter($expectedExpiry * 2)->shouldBeCalledOnce()->willReturn($cooldownCacheItemAttempt->reveal()); + $cache->getItem('testkeytrustboundarycooldownattempt') + ->shouldBeCalledTimes(2) + ->willReturn($cooldownCacheItemAttempt->reveal()); + $cache->save($cooldownCacheItemAttempt->reveal())->shouldBeCalledOnce()->willReturn(true); + + $this->impl->setCache($cache->reveal()); + + $mock = new MockHandler([ + new RequestException('Error Communicating with Server (1)', new Request('GET', 'test')), + ]); + $handler = HttpHandlerFactory::build(new Client(['handler' => $mock])); + + // First call, should fetch and cache + $result1 = $this->impl->getTrustBoundary( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $handler, + 'default', + ['authorization' => ['xyz']] + ); + + $this->assertNull($result1); + } } class TrustBoundaryTraitImpl From 6a89ee38b725c05c7418e10f3014a4ae4d45fe1b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 9 Mar 2026 11:51:50 -0700 Subject: [PATCH 21/21] fix cooldown cs --- src/TrustBoundaryTrait.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TrustBoundaryTrait.php b/src/TrustBoundaryTrait.php index 0cf9147eac..97de6b2440 100644 --- a/src/TrustBoundaryTrait.php +++ b/src/TrustBoundaryTrait.php @@ -161,7 +161,7 @@ private function lookupTrustBoundary( return null; } - private function initiateCooldown() + private function initiateCooldown(): void { $cooldownKey = $this->getCacheKey() . ':trustboundary:cooldown'; $attempt = $this->getCachedValue($cooldownKey . ':attempt') ?? 0; @@ -172,12 +172,12 @@ private function initiateCooldown() $this->setCachedValue( $cooldownKey, true, - $cooldownPeriod + (int) $cooldownPeriod ); $this->setCachedValue( $cooldownKey . ':attempt', $attempt, - $cooldownPeriod * 2 + (int) $cooldownPeriod * 2 ); } }