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