Skip to content

Commit 21e7bd1

Browse files
committed
add trust boundary for ExternalAccountCredentials
1 parent 77710aa commit 21e7bd1

8 files changed

Lines changed: 163 additions & 55 deletions

src/Credentials/ExternalAccountCredentials.php

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@
3030
use Google\Auth\HttpHandler\HttpHandlerFactory;
3131
use Google\Auth\OAuth2;
3232
use Google\Auth\ProjectIdProviderInterface;
33+
use Google\Auth\TrustBoundaryTrait;
3334
use Google\Auth\UpdateMetadataInterface;
3435
use Google\Auth\UpdateMetadataTrait;
3536
use GuzzleHttp\Psr7\Request;
3637
use InvalidArgumentException;
38+
use LogicException;
3739

3840
/**
3941
* **IMPORTANT**:
@@ -51,7 +53,12 @@ class ExternalAccountCredentials implements
5153
GetUniverseDomainInterface,
5254
ProjectIdProviderInterface
5355
{
54-
use UpdateMetadataTrait;
56+
use UpdateMetadataTrait {
57+
updateMetadata as traitUpdateMetadata;
58+
}
59+
use TrustBoundaryTrait {
60+
buildTrustBoundaryLookupUrl as traitBuildTrustBoundaryLookupUrl;
61+
}
5562

5663
private const EXTERNAL_ACCOUNT_TYPE = 'external_account';
5764
private const CLOUD_RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.UNIVERSE_DOMAIN/v1/projects/%s';
@@ -69,10 +76,12 @@ class ExternalAccountCredentials implements
6976
* @param string|string[] $scope The scope of the access request, expressed either as an array
7077
* or as a space-delimited string.
7178
* @param array<mixed> $jsonKey JSON credentials as an associative array.
79+
* @param bool $enableTrustBoundary Lookup and include the trust boundary header.
7280
*/
7381
public function __construct(
7482
$scope,
75-
array $jsonKey
83+
array $jsonKey,
84+
bool $enableTrustBoundary = false
7685
) {
7786
if (!array_key_exists('type', $jsonKey)) {
7887
throw new InvalidArgumentException('json key is missing the type field');
@@ -114,6 +123,7 @@ public function __construct(
114123
$this->quotaProject = $jsonKey['quota_project_id'] ?? null;
115124
$this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null;
116125
$this->universeDomain = $jsonKey['universe_domain'] ?? GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN;
126+
$this->enableTrustBoundary = $enableTrustBoundary;
117127

118128
$this->auth = new OAuth2([
119129
'tokenCredentialUri' => $jsonKey['token_url'],
@@ -200,11 +210,8 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
200210
}
201211

202212
if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) {
203-
// Parse email from URL. The formal looks as follows:
204-
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
205-
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
206-
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
207-
$env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email'];
213+
if ($email = self::getServiceAccountImpersonationEmail($serviceAccountImpersonationUrl)) {
214+
$env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $email;
208215
}
209216
}
210217

@@ -220,6 +227,18 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
220227
throw new InvalidArgumentException('Unable to determine credential source from json key.');
221228
}
222229

230+
private static function getServiceAccountImpersonationEmail(string $serviceAccountImpersonationUrl): string|null
231+
{
232+
// Parse email from URL. The formal looks as follows:
233+
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
234+
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
235+
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
236+
return $matches['email'];
237+
}
238+
239+
return null;
240+
}
241+
223242
/**
224243
* @param string $stsToken
225244
* @param callable|null $httpHandler
@@ -290,6 +309,37 @@ public function fetchAuthToken(?callable $httpHandler = null, array $headers = [
290309
return $stsToken;
291310
}
292311

312+
/**
313+
* Updates metadata with the authorization token.
314+
*
315+
* @param array<mixed> $metadata metadata hashmap
316+
* @param string $authUri optional auth uri
317+
* @param callable|null $httpHandler callback which delivers psr7 request
318+
* @return array<mixed> updated metadata hashmap
319+
*/
320+
public function updateMetadata(
321+
$metadata,
322+
$authUri = null,
323+
?callable $httpHandler = null
324+
) {
325+
$metadata = $this->traitUpdateMetadata($metadata, $authUri, $httpHandler);
326+
327+
if ($this->enableTrustBoundary) {
328+
$clientName = $this->serviceAccountImpersonationUrl
329+
? self::getServiceAccountImpersonationEmail($this->serviceAccountImpersonationUrl)
330+
: ''; // @TODO: What do we do when this is empty?
331+
332+
$metadata = $this->updateTrustBoundaryMetadata(
333+
$metadata,
334+
$this->buildTrustBoundaryLookupUrl(),
335+
$this->getUniverseDomain($httpHandler),
336+
$httpHandler,
337+
);
338+
}
339+
340+
return $metadata;
341+
}
342+
293343
/**
294344
* Get the cache token key for the credentials.
295345
* The cache token key format depends on the type of source
@@ -391,4 +441,32 @@ private function isWorkforcePool(): bool
391441
$regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#';
392442
return preg_match($regex, $this->auth->getAudience()) === 1;
393443
}
444+
445+
/**
446+
* Builds and returns the URL for the trust boundary lookup API.
447+
*/
448+
private function buildTrustBoundaryLookupUrl(): string
449+
{
450+
// Try to parse as a workload identity pool.
451+
// Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
452+
$regex = '/projects\/([^\/]+)\/locations\/global\/workloadIdentityPools\/([^\/]+)/';
453+
if (preg_match($regex, $this->auth->getAudience(), $matches)) {
454+
[$_, $projectNumber, $poolId] = $matches;
455+
456+
return $this->traitBuildTrustBoundaryLookupUrl(
457+
poolId: $poolId,
458+
projectNumber: $projectNumber,
459+
);
460+
}
461+
462+
// If that fails, try to parse as a workforce pool.
463+
// Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
464+
if (preg_match('/locations\/[^\/]+\/workforcePools\/([^\/]+)/', $this->auth->getAudience(), $matches)) {
465+
return $this->traitBuildTrustBoundaryLookupUrl(
466+
poolId: $matches[1],
467+
);
468+
}
469+
470+
throw new LogicException("Invalid audience format.");
471+
}
394472
}

src/Credentials/GCECredentials.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,9 @@ public function updateMetadata(
652652
if ($this->enableTrustBoundary) {
653653
$metadata = $this->updateTrustBoundaryMetadata(
654654
$metadata,
655-
$this->getClientName($httpHandler),
655+
$this->buildTrustBoundaryLookupUrl(
656+
serviceAccountEmail: $this->getClientName($httpHandler)
657+
),
656658
$this->getUniverseDomain($httpHandler),
657659
$httpHandler,
658660
);

src/Credentials/ImpersonatedServiceAccountCredentials.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,9 @@ public function updateMetadata(
327327

328328
$metatadata = $this->updateTrustBoundaryMetadata(
329329
$metatadata,
330-
$this->impersonatedServiceAccountName,
330+
$this->buildTrustBoundaryLookupUrl(
331+
serviceAccountEmail: $this->impersonatedServiceAccountName
332+
),
331333
$this->getUniverseDomain(),
332334
$httpHandler,
333335
);

src/Credentials/ServiceAccountCredentials.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,9 @@ public function updateMetadata(
332332

333333
$metadata = $this->updateTrustBoundaryMetadata(
334334
$metadata,
335-
$this->auth->getIssuer(),
335+
$this->buildTrustBoundaryLookupUrl(
336+
serviceAccountEmail: $this->auth->getIssuer()
337+
),
336338
$this->getUniverseDomain(),
337339
$httpHandler,
338340
);

src/CredentialsLoader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ public static function makeCredentials(
186186

187187
if ($jsonKey['type'] == 'external_account') {
188188
$anyScope = $scope ?: $defaultScope;
189-
return new ExternalAccountCredentials($anyScope, $jsonKey);
189+
return new ExternalAccountCredentials($anyScope, $jsonKey, $enableTrustBoundary);
190190
}
191191

192192
throw new \InvalidArgumentException('invalid value in the type field');

src/TrustBoundaryTrait.php

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Google\Auth\HttpHandler\HttpHandlerFactory;
77
use GuzzleHttp\Exception\ClientException;
88
use GuzzleHttp\Psr7\Request;
9+
use InvalidArgumentException;
910

1011
/**
1112
* @internal
@@ -23,7 +24,7 @@ trait TrustBoundaryTrait
2324
private function getTrustBoundary(
2425
string $universeDomain,
2526
callable $httpHandler,
26-
string $serviceAccountEmail,
27+
string $trustBoundaryUrl,
2728
array $headers,
2829
): array|null {
2930
if (!$this->enableTrustBoundary) {
@@ -48,7 +49,7 @@ private function getTrustBoundary(
4849

4950
$trustBoundary = $this->lookupTrustBoundary(
5051
$httpHandler,
51-
$serviceAccountEmail,
52+
$trustBoundaryUrl,
5253
$headers['authorization']
5354
);
5455

@@ -62,44 +63,13 @@ private function getTrustBoundary(
6263
return $trustBoundary;
6364
}
6465

65-
/**
66-
* @param array<string> $authHeader
67-
* @return null|array{locations: array<string>, encodedLocations: string}
68-
*/
69-
private function lookupTrustBoundary(
70-
callable $httpHandler,
71-
string $serviceAccountEmail,
72-
array $authHeader
73-
): array|null {
74-
$url = $this->buildTrustBoundaryLookupUrl($serviceAccountEmail);
75-
$request = new Request('GET', $url);
76-
$request = $request->withHeader('authorization', $authHeader);
77-
try {
78-
$response = $httpHandler($request);
79-
return json_decode((string) $response->getBody(), true);
80-
} catch (ClientException $e) {
81-
// We swallow all errors here - a failed trust boundary lookup
82-
// should not disrupt client authentication.
83-
}
84-
return null;
85-
}
86-
87-
private function buildTrustBoundaryLookupUrl(string $serviceAccountEmail): string
88-
{
89-
return sprintf(
90-
'https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s/allowedLocations',
91-
$this->getUniverseDomain(),
92-
$serviceAccountEmail
93-
);
94-
}
95-
9666
/**
9767
* @param array<mixed> $headers
9868
* @return array<mixed>
9969
*/
10070
private function updateTrustBoundaryMetadata(
10171
array $headers,
102-
string $serviceAccountEmail,
72+
string $trustBoundaryUrl,
10373
string $universeDomain,
10474
?callable $httpHandler,
10575
): array {
@@ -109,7 +79,7 @@ private function updateTrustBoundaryMetadata(
10979
$trustBoundaryInfo = $this->getTrustBoundary(
11080
$universeDomain,
11181
$httpHandler,
112-
$serviceAccountEmail,
82+
$trustBoundaryUrl,
11383
$headers
11484
);
11585

@@ -119,4 +89,63 @@ private function updateTrustBoundaryMetadata(
11989

12090
return $headers;
12191
}
92+
93+
/**
94+
* Return the trust boundary lookup URL.
95+
*/
96+
private function buildTrustBoundaryLookupUrl(
97+
?string $serviceAccountEmail = null,
98+
?string $poolId = null,
99+
?string $projectNumber = null,
100+
): string {
101+
$baseUrl = 'https://iamcredentials.googleapis.com/v1';
102+
if ($serviceAccountEmail) {
103+
if (is_null($projectNumber) && is_null($poolId)) {
104+
return sprintf(
105+
'%s/projects/-/serviceAccounts/%s/allowedLocations',
106+
$baseUrl,
107+
$serviceAccountEmail
108+
);
109+
}
110+
} elseif ($poolId) {
111+
if (is_null($projectNumber)) {
112+
// Workforce Identity Pools
113+
return sprintf(
114+
'%s/locations/global/workforcePools/%s/allowedLocations',
115+
$baseUrl,
116+
$poolId
117+
);
118+
}
119+
// Workload Identity Pools
120+
return sprintf(
121+
'%s/projects/%s/locations/global/workloadIdentityPools/%s/allowedLocations',
122+
$baseUrl,
123+
$projectNumber,
124+
$poolId
125+
);
126+
}
127+
128+
throw new InvalidArgumentException('Must supply $serviceAccountEmail, $poolId, or both $poolId and $projectId');
129+
}
130+
131+
/**
132+
* @param array<string> $authHeader
133+
* @return null|array{locations: array<string>, encodedLocations: string}
134+
*/
135+
private function lookupTrustBoundary(
136+
callable $httpHandler,
137+
string $trustBoundaryUrl,
138+
array $authHeader
139+
): array|null {
140+
$request = new Request('GET', $trustBoundaryUrl);
141+
$request = $request->withHeader('authorization', $authHeader);
142+
try {
143+
$response = $httpHandler($request);
144+
return json_decode((string) $response->getBody(), true);
145+
} catch (ClientException $e) {
146+
// We swallow all errors here - a failed trust boundary lookup
147+
// should not disrupt client authentication.
148+
}
149+
return null;
150+
}
122151
}

tests/ApplicationDefaultCredentialsTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -909,8 +909,8 @@ public function testTrustBoundaryLookupIntegration()
909909
{
910910
if ('true' !== getenv('RUN_TRUST_BOUNDARY_TESTS')) {
911911
$this->markTestSkipped(
912-
'This test requires RUN_TRUST_BOUNDARY_TESTS=true and a set of credentials with' .
913-
'Trust boundaries enabled'
912+
'This test requires RUN_TRUST_BOUNDARY_TESTS=true and a set of credentials with ' .
913+
'trust boundaries enabled'
914914
);
915915
}
916916

tests/TrustBoundaryTraitTest.php

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ public function setUp(): void
2121

2222
public function testBuildTrustBoundaryLookupUrl()
2323
{
24-
$url = $this->impl->buildTrustBoundaryLookupUrl('test@example.com');
24+
$url = $this->impl->buildTrustBoundaryLookupUrl(serviceAccountEmail: 'test@example.com');
2525
$this->assertEquals(
26-
'https://iamcredentials.foo.bar/v1/projects/-/serviceAccounts/test@example.com/allowedLocations',
26+
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@example.com/allowedLocations',
2727
$url
2828
);
2929
}
@@ -101,9 +101,4 @@ public function setCache($cache)
101101
{
102102
$this->cache = $cache;
103103
}
104-
105-
public function getUniverseDomain()
106-
{
107-
return 'foo.bar';
108-
}
109104
}

0 commit comments

Comments
 (0)