Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support universe domain in service account and metadata credentials #482

Merged
merged 24 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dc60bd7
feat: add universe domain support
bshaffer Sep 6, 2023
5147cf9
fix GCE creds, misc cleanup
bshaffer Sep 7, 2023
f5b53a8
fix phpstan
bshaffer Sep 7, 2023
371e82b
Merge branch 'main' into add-universe-domain-phase1
bshaffer Sep 7, 2023
ca7fb2d
add universe domain to ADC
bshaffer Sep 7, 2023
6a89d34
Update src/Credentials/GCECredentials.php
bshaffer Sep 11, 2023
602e2fe
add comment regarding 404 on GCE
bshaffer Sep 12, 2023
961e021
return default universe domain when MDS returns empty string
bshaffer Sep 12, 2023
dd75d5b
add validation for user-configured universe domain
bshaffer Sep 12, 2023
90cb62e
Revert "add validation for user-configured universe domain"
bshaffer Sep 13, 2023
a068736
remove support for universe_domain in user credentials
bshaffer Sep 13, 2023
569c4fe
simplify user refresh
bshaffer Sep 13, 2023
1d3ec01
remove universe domain support in GCECredentials
bshaffer Sep 20, 2023
ca9192a
remove logic for passing of universeDomain
bshaffer Sep 20, 2023
f5ece95
remove unused methods
bshaffer Sep 20, 2023
be34ff3
Revert "remove universe domain support in GCECredentials"
bshaffer Sep 20, 2023
8a0bb41
Revert "remove logic for passing of universeDomain"
bshaffer Sep 20, 2023
098a4e9
Revert "remove unused methods"
bshaffer Sep 20, 2023
6df0420
implement GetUniverseDomainInterface for credentials cache
bshaffer Oct 25, 2023
b216580
always use self-signed JWT when universe domain is outside GDU
bshaffer Oct 26, 2023
ad09ca1
Merge branch 'main' into add-universe-domain-phase2
bshaffer Nov 28, 2023
50d81c9
Update src/Credentials/GCECredentials.php
bshaffer Nov 28, 2023
5f8b409
throw exception for domain-wide delegation outside GDU
bshaffer Dec 13, 2023
4202dcf
Merge branch 'main' into add-universe-domain-phase2
bshaffer Dec 13, 2023
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
10 changes: 8 additions & 2 deletions src/ApplicationDefaultCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ public static function getMiddleware(
* @param string|string[] $defaultScope The default scope to use if no
* user-defined scopes exist, expressed either as an Array or as a
* space-delimited string.
* @param string $universeDomain Specifies a universe domain to use for the
* calling client library
*
* @return FetchAuthTokenInterface
* @throws DomainException if no implementation can be obtained.
Expand All @@ -154,7 +156,8 @@ public static function getCredentials(
array $cacheConfig = null,
CacheItemPoolInterface $cache = null,
$quotaProject = null,
$defaultScope = null
$defaultScope = null,
string $universeDomain = null
) {
$creds = null;
$jsonKey = CredentialsLoader::fromEnv()
Expand All @@ -179,6 +182,9 @@ public static function getCredentials(
if ($quotaProject) {
$jsonKey['quota_project_id'] = $quotaProject;
}
if ($universeDomain) {
$jsonKey['universe_domain'] = $universeDomain;
}
$creds = CredentialsLoader::makeCredentials(
$scope,
$jsonKey,
Expand All @@ -187,7 +193,7 @@ 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);
$creds = new GCECredentials(null, $anyScope, null, $quotaProject, null, $universeDomain);
$creds->setIsOnGce(true); // save the credentials a trip to the metadata server
}

Expand Down
78 changes: 77 additions & 1 deletion src/Credentials/GCECredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ class GCECredentials extends CredentialsLoader implements
*/
const PROJECT_ID_URI_PATH = 'v1/project/project-id';

/**
* The metadata path of the project ID.
*/
const UNIVERSE_DOMAIN_URI_PATH = 'v1/universe/universe_domain';

/**
* The header whose presence indicates GCE presence.
*/
Expand Down Expand Up @@ -169,6 +174,11 @@ class GCECredentials extends CredentialsLoader implements
*/
private $serviceAccountIdentity;

/**
* @var string
*/
private ?string $universeDomain;

/**
* @param Iam $iam [optional] An IAM instance.
* @param string|string[] $scope [optional] the scope of the access request,
Expand All @@ -178,13 +188,16 @@ class GCECredentials extends CredentialsLoader implements
* charges associated with the request.
* @param string $serviceAccountIdentity [optional] Specify a service
* account identity name to use instead of "default".
* @param string $universeDomain [optional] Specify a universe domain to use
* instead of fetching one from the metadata server.
*/
public function __construct(
Iam $iam = null,
$scope = null,
$targetAudience = null,
$quotaProject = null,
$serviceAccountIdentity = null
$serviceAccountIdentity = null,
string $universeDomain = null
) {
$this->iam = $iam;

Expand Down Expand Up @@ -212,6 +225,7 @@ public function __construct(
$this->tokenUri = $tokenUri;
$this->quotaProject = $quotaProject;
$this->serviceAccountIdentity = $serviceAccountIdentity;
$this->universeDomain = $universeDomain;
}

/**
Expand Down Expand Up @@ -294,6 +308,18 @@ private static function getProjectIdUri()
return $base . self::PROJECT_ID_URI_PATH;
}

/**
* The full uri for accessing the default universe domain.
*
* @return string
*/
private static function getUniverseDomainUri()
{
$base = 'http://' . self::METADATA_IP . '/computeMetadata/';

return $base . self::UNIVERSE_DOMAIN_URI_PATH;
}

/**
* Determines if this an App Engine Flexible instance, by accessing the
* GAE_INSTANCE environment variable.
Expand Down Expand Up @@ -500,6 +526,56 @@ public function getProjectId(callable $httpHandler = null)
return $this->projectId;
}

/**
* Fetch the default universe domain from the metadata server.
*
* Returns null if called outside GCE.
*
* @param callable $httpHandler Callback which delivers psr7 request
* @return string
*/
public function getUniverseDomain(callable $httpHandler = null): string
{
if ($this->universeDomain) {
bshaffer marked this conversation as resolved.
Show resolved Hide resolved
return $this->universeDomain;
}

$httpHandler = $httpHandler
?: HttpHandlerFactory::build(HttpClientCache::getHttpClient());

if (!$this->hasCheckedOnGce) {
$this->isOnGce = self::onGce($httpHandler);
$this->hasCheckedOnGce = true;
}

if (!$this->isOnGce) {
return self::DEFAULT_UNIVERSE_DOMAIN;
}
vishwarajanand marked this conversation as resolved.
Show resolved Hide resolved

try {
$this->universeDomain = $this->getFromMetadata(
$httpHandler,
self::getUniverseDomainUri()
);
} catch (ClientException $e) {
// If the metadata server exists, but returns a 404 for the universe domain, the auth
// libraries should safely assume this is an older metadata server running in GCU, and
// should return the default universe domain.
if (!$e->hasResponse() || 404 != $e->getResponse()->getStatusCode()) {
throw $e;
}
$this->universeDomain = self::DEFAULT_UNIVERSE_DOMAIN;
}

// We expect in some cases the metadata server will return an empty string for the universe
// domain. In this case, the auth library MUST return the default universe domain.
if ('' === $this->universeDomain) {
vishwarajanand marked this conversation as resolved.
Show resolved Hide resolved
$this->universeDomain = self::DEFAULT_UNIVERSE_DOMAIN;
}

bshaffer marked this conversation as resolved.
Show resolved Hide resolved
return $this->universeDomain;
}

/**
* Fetch the value of a GCE metadata server URI.
*
Expand Down
15 changes: 9 additions & 6 deletions src/Credentials/ServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ class ServiceAccountCredentials extends CredentialsLoader implements
private $jwtAccessCredentials;

/**
* @var string|null
* @var string
*/
private ?string $universeDomain;
private string $universeDomain;

/**
* Create a new ServiceAccountCredentials.
Expand Down Expand Up @@ -164,7 +164,7 @@ public function __construct(
]);

$this->projectId = $jsonKey['project_id'] ?? null;
$this->universeDomain = $jsonKey['universe_domain'] ?? null;
$this->universeDomain = $jsonKey['universe_domain'] ?? self::DEFAULT_UNIVERSE_DOMAIN;
Copy link
Contributor

@vishwarajanand vishwarajanand Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also throw in case there's domain-delegation?
I am not very sure but it appears that we need to throw (near line#146) in case sub as well as universe_domain is set in the key, as per AL9 of the universe domain spec

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for catching this, I completely missed it. PHP definitely supports domain-delegation

* @param string $sub an email address account to impersonate, in situations when
* the service account has been delegated domain wide access.

I'll look more into it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I added the exception and test in 5f8b409, and added a fix for domain-wide delegation that we had missed in #505

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved it

}

/**
Expand Down Expand Up @@ -341,9 +341,6 @@ public function getQuotaProject()
*/
public function getUniverseDomain(): string
{
if (null === $this->universeDomain) {
return self::DEFAULT_UNIVERSE_DOMAIN;
}
return $this->universeDomain;
}

Expand All @@ -361,6 +358,12 @@ private function useSelfSignedJwt()
if ($this->useJwtAccessWithScope) {
return true;
}

// If the universe domain is outside the GDU, use JwtAccess for access tokens
if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) {
return true;
}

return is_null($this->auth->getScope());
}
}
52 changes: 52 additions & 0 deletions tests/ApplicationDefaultCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -804,5 +804,57 @@ public function testUniverseDomainInKeyFile()
putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile);
$creds2 = ApplicationDefaultCredentials::getCredentials();
$this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain());

// test passing in a different universe domain for "authenticated_user" has no effect.
$creds3 = ApplicationDefaultCredentials::getCredentials(
null,
null,
null,
null,
null,
null,
'example-universe2.com'
);
$this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds3->getUniverseDomain());
}

/** @runInSeparateProcess */
public function testUniverseDomainInGceCredentials()
{
putenv('HOME');

$expectedUniverseDomain = 'example-universe.com';
$creds = ApplicationDefaultCredentials::getCredentials(
null, // $scope
$httpHandler = getHandler([
new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']),
new Response(200, [], Utils::streamFor($expectedUniverseDomain)),
]) // $httpHandler
);
$this->assertEquals('example-universe.com', $creds->getUniverseDomain($httpHandler));

// test passing in a different universe domain overrides metadata server
$creds2 = ApplicationDefaultCredentials::getCredentials(
null, // $scope
$httpHandler = getHandler([
new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']),
]), // $httpHandler
null, // $cacheConfig
null, // $cache
null, // $quotaProject
null, // $defaultScope
'example-universe2.com' // $universeDomain
);
$this->assertEquals('example-universe2.com', $creds2->getUniverseDomain($httpHandler));

// test error response returns default universe domain
$creds2 = ApplicationDefaultCredentials::getCredentials(
null, // $scope
$httpHandler = getHandler([
new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']),
new Response(404),
]), // $httpHandler
);
$this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain($httpHandler));
}
}
57 changes: 55 additions & 2 deletions tests/Credentials/GCECredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,60 @@ public function testGetUniverseDomain()
{
$creds = new GCECredentials();

// Universe domain should always be the default
$this->assertEquals(GCECredentials::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain());
// If we are not on GCE, this should return the default
$creds->setIsOnGce(false);
$this->assertEquals(
GCECredentials::DEFAULT_UNIVERSE_DOMAIN,
$creds->getUniverseDomain()
);

// Pretend we are on GCE and mock the http handler.
$expected = 'example-universe.com';
$timesCalled = 0;
$httpHandler = function ($request) use (&$timesCalled, $expected) {
$timesCalled++;
$this->assertEquals(
'/computeMetadata/v1/universe/universe_domain',
$request->getUri()->getPath()
);
$this->assertEquals(1, $timesCalled, 'should only be called once');
return new Psr7\Response(200, [], Utils::streamFor($expected));
};

$creds->setIsOnGce(true);

// Assert correct universe domain.
$this->assertEquals($expected, $creds->getUniverseDomain($httpHandler));

// Assert the result is cached for subsequent calls.
$this->assertEquals($expected, $creds->getUniverseDomain($httpHandler));
}

public function testGetUniverseDomainEmptyStringReturnsDefault()
{
$creds = new GCECredentials();
$creds->setIsOnGce(true);

// Pretend we are on GCE and mock the MDS returning an empty string for the universe domain.
$httpHandler = function ($request) {
$this->assertEquals(
'/computeMetadata/v1/universe/universe_domain',
$request->getUri()->getPath()
);
return new Psr7\Response(200, [], Utils::streamFor(''));
};

// Assert the default universe domain is returned instead of the empty string.
$this->assertEquals(
GCECredentials::DEFAULT_UNIVERSE_DOMAIN,
$creds->getUniverseDomain($httpHandler)
);
}
bshaffer marked this conversation as resolved.
Show resolved Hide resolved

public function testExplicitUniverseDomain()
{
$expected = 'example-universe.com';
$creds = new GCECredentials(null, null, null, null, null, $expected);
$this->assertEquals($expected, $creds->getUniverseDomain());
}
}
39 changes: 39 additions & 0 deletions tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -503,4 +503,43 @@ public function testGetQuotaProject()
$sa = new ServiceAccountJwtAccessCredentials($keyFile);
$this->assertEquals('test_quota_project', $sa->getQuotaProject());
}

public function testUpdateMetadataWithUniverseDomainAlwaysUsesJwtAccess()
{
$testJson = $this->createTestJson() + ['universe_domain' => 'abc.xyz'];
// jwt access should always be used when the universe domain is set,
// even if scopes are supplied but useJwtAccessWithScope is false
$scope = ['scope1', 'scope2'];
$sa = new ServiceAccountCredentials(
$scope,
$testJson
);

$metadata = $sa->updateMetadata(
['foo' => 'bar'],
'https://example.com/service'
);

$this->assertArrayHasKey(
CredentialsLoader::AUTH_METADATA_KEY,
$metadata
);

$authorization = $metadata[CredentialsLoader::AUTH_METADATA_KEY];
$this->assertTrue(is_array($authorization));

$token = current($authorization);
$this->assertTrue(is_string($token));
$this->assertEquals(0, strpos($token, 'Bearer '));

// Ensure token is a self-signed JWT
$token = substr($token, strlen('Bearer '));
$this->assertEquals(2, substr_count($token, '.'));
list($header, $payload, $sig) = explode('.', $token);
$json = json_decode(base64_decode($payload), true);
$this->assertTrue(is_array($json));
// Ensure scopes exist
$this->assertArrayHasKey('scope', $json);
$this->assertEquals($json['scope'], implode(' ', $scope));
}
}