diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cebf8c5b6..36bc8ea4d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3fab204de..cc9ef8aa6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: php: [ "7.4", "8.0", "8.1", "8.2", "8.3" ] name: PHP ${{matrix.php }} Unit Test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest name: Test Prefer Lowest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest name: PHP Style Check steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest name: PHPStan Static Analysis steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec078dcf..4bc891fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.33.0](https://github.com/googleapis/google-auth-library-php/compare/v1.32.1...v1.33.0) (2023-11-29) + + +### Features + +* Add and implement universe domain interface ([#477](https://github.com/googleapis/google-auth-library-php/issues/477)) ([35781ed](https://github.com/googleapis/google-auth-library-php/commit/35781ed573aa9d831d38452eefbac790559dfb97)) + +### Miscellaneous + +* Refactor `AuthTokenMiddleware` ([#492](https://github.com/googleapis/google-auth-library-php/pull/492)) + ## [1.32.1](https://github.com/googleapis/google-auth-library-php/compare/v1.32.0...v1.32.1) (2023-10-17) diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..7aa332e41 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.33.0 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a7a92e4dd..2e2253269 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + src diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index d556fac4e..80437c8c9 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -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. @@ -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() @@ -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, @@ -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 } diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 991589b52..7849eccfc 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -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. */ @@ -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, @@ -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; @@ -212,6 +225,7 @@ public function __construct( $this->tokenUri = $tokenUri; $this->quotaProject = $quotaProject; $this->serviceAccountIdentity = $serviceAccountIdentity; + $this->universeDomain = $universeDomain; } /** @@ -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. @@ -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 (null !== $this->universeDomain) { + 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; + } + + 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) { + $this->universeDomain = self::DEFAULT_UNIVERSE_DOMAIN; + } + + return $this->universeDomain; + } + /** * Fetch the value of a GCE metadata server URI. * diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 086417c07..eba43cf9f 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -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. @@ -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; } /** @@ -341,9 +341,6 @@ public function getQuotaProject() */ public function getUniverseDomain(): string { - if (null === $this->universeDomain) { - return self::DEFAULT_UNIVERSE_DOMAIN; - } return $this->universeDomain; } @@ -352,6 +349,20 @@ public function getUniverseDomain(): string */ private function useSelfSignedJwt() { + // When a sub is supplied, the user is using domain-wide delegation, which not available + // with self-signed JWTs + if (null !== $this->auth->getSub()) { + // If we are outside the GDU, we can't use domain-wide delegation + if ($this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) { + throw new \LogicException(sprintf( + 'Service Account subject is configured for the credential. Domain-wide ' . + 'delegation is not supported in universes other than %s.', + self::DEFAULT_UNIVERSE_DOMAIN + )); + } + return false; + } + // If claims are set, this call is for "id_tokens" if ($this->auth->getAdditionalClaims()) { return false; @@ -361,6 +372,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()); } } diff --git a/src/Middleware/AuthTokenMiddleware.php b/src/Middleware/AuthTokenMiddleware.php index b10cf9bff..798766efa 100644 --- a/src/Middleware/AuthTokenMiddleware.php +++ b/src/Middleware/AuthTokenMiddleware.php @@ -132,7 +132,7 @@ private function addAuthHeaders(RequestInterface $request) ) { $token = $this->fetcher->fetchAuthToken(); $request = $request->withHeader( - 'authorization', 'Bearer ' . ($token['access_token'] ?? $token['id_token']) + 'authorization', 'Bearer ' . ($token['access_token'] ?? $token['id_token'] ?? '') ); } else { $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 2aeb3ab3a..69cc05fed 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -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)); } } diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 9369e40ac..695ba0195 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -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) + ); + } + + public function testExplicitUniverseDomain() + { + $expected = 'example-universe.com'; + $creds = new GCECredentials(null, null, null, null, null, $expected); + $this->assertEquals($expected, $creds->getUniverseDomain()); } } diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index c6ff2520d..a53f55158 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -307,6 +307,29 @@ public function testShouldBeIdTokenWhenTargetAudienceIsSet() $this->assertEquals(1, $timesCalled); } + public function testShouldBeOAuthRequestWhenSubIsSet() + { + $testJson = $this->createTestJson(); + $sub = 'sub12345'; + $timesCalled = 0; + $httpHandler = function ($request) use (&$timesCalled, $sub) { + $timesCalled++; + parse_str($request->getBody(), $post); + $this->assertArrayHasKey('assertion', $post); + list($header, $payload, $sig) = explode('.', $post['assertion']); + $jwtParams = json_decode(base64_decode($payload), true); + $this->assertArrayHasKey('sub', $jwtParams); + $this->assertEquals($sub, $jwtParams['sub']); + + return new Psr7\Response(200, [], Utils::streamFor(json_encode([ + 'access_token' => 'token123' + ]))); + }; + $sa = new ServiceAccountCredentials(null, $testJson, $sub); + $this->assertEquals('token123', $sa->fetchAuthToken($httpHandler)['access_token']); + $this->assertEquals(1, $timesCalled); + } + public function testSettingBothScopeAndTargetAudienceThrowsException() { $this->expectException(InvalidArgumentException::class); @@ -321,6 +344,24 @@ public function testSettingBothScopeAndTargetAudienceThrowsException() ); } + public function testDomainWideDelegationOutsideGduThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Service Account subject is configured for the credential. Domain-wide ' . + 'delegation is not supported in universes other than googleapis.com' + ); + $testJson = $this->createTestJson() + ['universe_domain' => 'abc.xyz']; + $sub = 'sub123'; + $sa = new ServiceAccountCredentials( + null, + $testJson, + $sub + ); + + $sa->fetchAuthToken(); + } + public function testReturnsClientEmail() { $testJson = $this->createTestJson(); diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index dc61e2ee4..510225dd7 100644 --- a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php +++ b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -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)); + } } diff --git a/tests/FetchAuthTokenTest.php b/tests/FetchAuthTokenTest.php index 6fe7df242..433dbe851 100644 --- a/tests/FetchAuthTokenTest.php +++ b/tests/FetchAuthTokenTest.php @@ -168,6 +168,8 @@ public function testServiceAccountCredentialsGetLastReceivedToken() ->willReturn($this->scopes); $oauth2Mock->getAdditionalClaims() ->willReturn([]); + $oauth2Mock->getSub() + ->willReturn(null); $credentials = new ServiceAccountCredentials($this->scopes, $jsonPath); $property->setValue($credentials, $oauth2Mock->reveal());