Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6413043
initial port of trust boundaries
bshaffer Jan 30, 2026
a4bf748
add enableTrustBoundary auth param, fix tests
bshaffer Jan 30, 2026
872cbf9
fix styles
bshaffer Feb 3, 2026
ab84972
more styles fixes
bshaffer Feb 3, 2026
8d9f2d0
final phpstan fixes?
bshaffer Feb 4, 2026
d68df71
implement trust boundaries for real
bshaffer Feb 6, 2026
cd52df6
add tests for GCECredentials
bshaffer Feb 6, 2026
ec2bc8a
fix styles
bshaffer Feb 6, 2026
bb18b80
ensure trust boundary lookup receives auth token
bshaffer Feb 9, 2026
a425328
ensure enableTrustBoundary is passed to GCECredentials
bshaffer Feb 10, 2026
a3559bc
fix default client name for GCE
bshaffer Feb 10, 2026
94e0416
add integration test for trust boundary headers
bshaffer Feb 10, 2026
f881c9e
Merge branch 'main' into trust-boundaries
bshaffer Feb 17, 2026
77710aa
fix fixtures path in tests
bshaffer Feb 24, 2026
21e7bd1
add trust boundary for ExternalAccountCredentials
bshaffer Feb 24, 2026
5155556
fix styles and tests
bshaffer Feb 24, 2026
f564f87
fix more styles and tests
bshaffer Feb 24, 2026
4999bdc
Add 6-hour TTL for location cache, add test for universe domain skip
bshaffer Mar 6, 2026
9937e3d
test for the lookup swallowing errors
bshaffer Mar 6, 2026
e25b47f
fix cache mocks in tests
bshaffer Mar 6, 2026
211488d
add cooldown
bshaffer Mar 6, 2026
6a89ee3
fix cooldown cs
bshaffer Mar 9, 2026
35b4874
Merge branch 'main' into trust-boundaries
bshaffer May 20, 2026
9cf4b9d
ensure trust boundaries are retrieved if x-allowed-locations exists
bshaffer May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/ApplicationDefaultCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand All @@ -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
}

Expand Down
5 changes: 3 additions & 2 deletions src/CacheTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down
92 changes: 85 additions & 7 deletions src/Credentials/ExternalAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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';
Expand All @@ -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<mixed> $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');
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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\/(?<email>[^:]+):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;
}
}

Expand All @@ -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\/(?<email>[^:]+):generateAccessToken$/';
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
return $matches['email'];
}

return null;
}

/**
* @param string $stsToken
* @param callable|null $httpHandler
Expand Down Expand Up @@ -290,6 +309,37 @@ public function fetchAuthToken(?callable $httpHandler = null, array $headers = [
return $stsToken;
}

/**
* Updates metadata with the authorization token.
*
* @param array<mixed> $metadata metadata hashmap
* @param string $authUri optional auth uri
* @param callable|null $httpHandler callback which delivers psr7 request
* @return array<mixed> 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
Expand Down Expand Up @@ -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');
}
}
36 changes: 35 additions & 1 deletion src/Credentials/GCECredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +65,7 @@ class GCECredentials extends CredentialsLoader implements
GetQuotaProjectInterface
{
use IamSignerTrait;
use TrustBoundaryTrait;

// phpcs:disable
const cacheKey = 'GOOGLE_AUTH_PHP_GCE';
Expand Down Expand Up @@ -209,14 +211,16 @@ 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,
$scope = null,
$targetAudience = null,
$quotaProject = null,
$serviceAccountIdentity = null,
?string $universeDomain = null
?string $universeDomain = null,
bool $enableTrustBoundary = false
) {
$this->iam = $iam;

Expand Down Expand Up @@ -245,6 +249,7 @@ public function __construct(
$this->quotaProject = $quotaProject;
$this->serviceAccountIdentity = $serviceAccountIdentity;
$this->universeDomain = $universeDomain;
$this->enableTrustBoundary = $enableTrustBoundary;
}

/**
Expand Down Expand Up @@ -629,6 +634,35 @@ public function getUniverseDomain(?callable $httpHandler = null): string
return $this->universeDomain;
}

/**
* Updates metadata with the authorization token.
*
* @param array<mixed> $metadata metadata hashmap
* @param string $authUri optional auth uri
* @param callable|null $httpHandler callback which delivers psr7 request
* @return array<mixed> 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.
*
Expand Down
Loading
Loading