diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..db8860b04 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release Pre-Check +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + release-suite: + runs-on: ubuntu-latest + name: Run googleapis/google-cloud-php tests against latest version + if: github.event.pull_request.user.login == 'release-please[bot]' + steps: + - uses: actions/checkout@v4 + - name: Clone googleapis/google-cloud-php + uses: actions/checkout@master + with: + repository: googleapis/google-cloud-php + path: google-cloud-php + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: grpc + - name: Configure google/auth to dev-main + run: | + cd google-cloud-php + composer install -q -d dev + dev/google-cloud update-deps google/auth 'dev-main as 1.200.0' --add=dev + - name: Run google/cloud package tests + run: | + cd google-cloud-php + bash .github/run-package-tests.sh + 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/README.md b/README.md index 91f12b2db..87f6f6064 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,18 @@ print_r((string) $response->getBody()); [iap-proxy-header]: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_proxy-authorization_header +#### External credentials (Workload identity federation) + +Using workload identity federation, your application can access Google Cloud resources from Amazon Web Services (AWS), +Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). + +Traditionally, applications running outside Google Cloud have used service account keys to access Google Cloud +resources. Using identity federation, you can allow your workload to impersonate a service account. This lets you access +Google Cloud resources directly, eliminating the maintenance and security burden associated with service account keys. + +Follow the detailed instructions on how to +[Configure Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds). + #### Verifying JWTs If you are [using Google ID tokens to authenticate users][google-id-tokens], use 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/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 76aa0fc99..086417c07 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -99,6 +99,11 @@ class ServiceAccountCredentials extends CredentialsLoader implements */ private $jwtAccessCredentials; + /** + * @var string|null + */ + private ?string $universeDomain; + /** * Create a new ServiceAccountCredentials. * @@ -159,6 +164,7 @@ public function __construct( ]); $this->projectId = $jsonKey['project_id'] ?? null; + $this->universeDomain = $jsonKey['universe_domain'] ?? null; } /** @@ -328,6 +334,19 @@ public function getQuotaProject() return $this->quotaProject; } + /** + * Get the universe domain configured in the JSON credential. + * + * @return string + */ + public function getUniverseDomain(): string + { + if (null === $this->universeDomain) { + return self::DEFAULT_UNIVERSE_DOMAIN; + } + return $this->universeDomain; + } + /** * @return bool */ diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 9e28701ed..746b957a9 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -30,6 +30,7 @@ * credentials files on the file system. */ abstract class CredentialsLoader implements + GetUniverseDomainInterface, FetchAuthTokenInterface, UpdateMetadataInterface { @@ -273,4 +274,15 @@ private static function loadDefaultClientCertSourceFile() } return $clientCertSourceJson; } + + /** + * Get the universe domain from the credential. Defaults to "googleapis.com" + * for all credential types which do not support universe domain. + * + * @return string + */ + public function getUniverseDomain(): string + { + return self::DEFAULT_UNIVERSE_DOMAIN; + } } diff --git a/src/FetchAuthTokenCache.php b/src/FetchAuthTokenCache.php index 47174a1b7..cac1984ab 100644 --- a/src/FetchAuthTokenCache.php +++ b/src/FetchAuthTokenCache.php @@ -26,6 +26,7 @@ class FetchAuthTokenCache implements FetchAuthTokenInterface, GetQuotaProjectInterface, + GetUniverseDomainInterface, SignBlobInterface, ProjectIdProviderInterface, UpdateMetadataInterface @@ -191,6 +192,20 @@ public function getProjectId(callable $httpHandler = null) return $this->fetcher->getProjectId($httpHandler); } + /* + * Get the Universe Domain from the fetcher. + * + * @return string + */ + public function getUniverseDomain(): string + { + if ($this->fetcher instanceof GetUniverseDomainInterface) { + return $this->fetcher->getUniverseDomain(); + } + + return GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + } + /** * Updates metadata with the authorization token. * diff --git a/src/GetUniverseDomainInterface.php b/src/GetUniverseDomainInterface.php new file mode 100644 index 000000000..1656ddc2e --- /dev/null +++ b/src/GetUniverseDomainInterface.php @@ -0,0 +1,35 @@ +withHeader('authorization', 'Bearer ' . $this->fetchToken()); + $request = $this->addAuthHeaders($request); if ($quotaProject = $this->getQuotaProject()) { $request = $request->withHeader( @@ -113,32 +119,33 @@ public function __invoke(callable $handler) } /** - * Call fetcher to fetch the token. + * Adds auth related headers to the request. * - * @return string|null + * @param RequestInterface $request + * @return RequestInterface */ - private function fetchToken() + private function addAuthHeaders(RequestInterface $request) { - $auth_tokens = (array) $this->fetcher->fetchAuthToken($this->httpHandler); - - if (array_key_exists('access_token', $auth_tokens)) { - // notify the callback if applicable - if ($this->tokenCallback) { - call_user_func( - $this->tokenCallback, - $this->fetcher->getCacheKey(), - $auth_tokens['access_token'] - ); - } - - return $auth_tokens['access_token']; + if (!$this->fetcher instanceof UpdateMetadataInterface || + ($this->fetcher instanceof FetchAuthTokenCache && + !$this->fetcher->getFetcher() instanceof UpdateMetadataInterface) + ) { + $token = $this->fetcher->fetchAuthToken(); + $request = $request->withHeader( + 'authorization', 'Bearer ' . ($token['access_token'] ?? $token['id_token']) + ); + } else { + $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); + $request = Utils::modifyRequest($request, ['set_headers' => $headers]); } - if (array_key_exists('id_token', $auth_tokens)) { - return $auth_tokens['id_token']; + if ($this->tokenCallback && ($token = $this->fetcher->getLastReceivedToken())) { + if (array_key_exists('access_token', $token)) { + call_user_func($this->tokenCallback, $this->fetcher->getCacheKey(), $token['access_token']); + } } - return null; + return $request; } /** diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index b3731f8bd..2aeb3ab3a 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -783,4 +783,26 @@ public function provideExternalAccountCredentials() ['aws_credentials.json', CredentialSource\AwsNativeSource::class], ]; } + + /** @runInSeparateProcess */ + public function testUniverseDomainInKeyFile() + { + // Test no universe domain in keyfile defaults to "googleapis.com" + $keyFile = __DIR__ . '/fixtures3/service_account_credentials.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain()); + + // Test universe domain in "service_account" keyfile + $keyFile = __DIR__ . '/fixtures/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals('example-universe.com', $creds->getUniverseDomain()); + + // Test universe domain in "authenticated_user" keyfile is not read. + $keyFile = __DIR__ . '/fixtures2/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds2 = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain()); + } } diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 0d36e6771..9369e40ac 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -512,4 +512,12 @@ public function testGetClientNameWithServiceAccountIdentity() $creds = new GCECredentials(null, null, null, null, 'foo'); $this->assertEquals($expected, $creds->getClientName($httpHandler)); } + + public function testGetUniverseDomain() + { + $creds = new GCECredentials(); + + // Universe domain should always be the default + $this->assertEquals(GCECredentials::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain()); + } } diff --git a/tests/FetchAuthTokenCacheTest.php b/tests/FetchAuthTokenCacheTest.php index f59c9295a..21a68e702 100644 --- a/tests/FetchAuthTokenCacheTest.php +++ b/tests/FetchAuthTokenCacheTest.php @@ -21,6 +21,7 @@ use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenCache; +use Google\Auth\GetUniverseDomainInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use RuntimeException; @@ -603,6 +604,41 @@ public function testGetProjectIdInvalidFetcher() $fetcher->getProjectId(); } + public function testGetUniverseDomain() + { + $universeDomain = 'foobar'; + + $mockFetcher = $this->prophesize('Google\Auth\GetUniverseDomainInterface'); + $mockFetcher->willImplement('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getUniverseDomain() + ->shouldBeCalled() + ->willReturn($universeDomain); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($universeDomain, $fetcher->getUniverseDomain()); + } + + public function testGetUniverseDomainInvalidFetcher() + { + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $fetcher->getUniverseDomain() + ); + } + public function testGetFetcher() { $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface') diff --git a/tests/FetchAuthTokenTest.php b/tests/FetchAuthTokenTest.php index 5b7badbe0..6fe7df242 100644 --- a/tests/FetchAuthTokenTest.php +++ b/tests/FetchAuthTokenTest.php @@ -25,6 +25,7 @@ use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\OAuth2; +use Google\Auth\UpdateMetadataInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -53,10 +54,20 @@ class_implements($fetcherClass) )) { $mockFetcher->getQuotaProject()->shouldBeCalledTimes(1); } - $mockFetcher->fetchAuthToken(Argument::any()) - ->shouldBeCalledTimes(1) - ->will($httpHandler); + + if (is_a($fetcherClass, UpdateMetadataInterface::class, true)) { + $mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1)->will(function () use (&$httpHandlerCalled) { + $httpHandlerCalled = true; + return ['authorization' => ['Bearer xyz']]; + }); + } else { + $mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->will($httpHandler); + } $mockFetcher->getCacheKey()->willReturn(''); + $mockFetcher->getLastReceivedToken()->willReturn(['access_token' => 'xyz']); $tokenCallbackCalled = false; $tokenCallback = function ($cacheKey, $accessToken) use (&$tokenCallbackCalled) { diff --git a/tests/Middleware/AuthTokenMiddlewareTest.php b/tests/Middleware/AuthTokenMiddlewareTest.php index 06c6ee485..cf0eb4182 100644 --- a/tests/Middleware/AuthTokenMiddlewareTest.php +++ b/tests/Middleware/AuthTokenMiddlewareTest.php @@ -20,7 +20,9 @@ use Google\Auth\FetchAuthTokenCache; use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\Tests\BaseTest; +use Google\Auth\UpdateMetadataInterface; use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -64,11 +66,7 @@ public function testAddsTheTokenAsAnAuthorizationHeader() ->shouldBeCalledTimes(1) ->willReturn($this->mockRequest->reveal()); - // Run the test. - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($this->mockFetcher->reveal()); } public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() @@ -80,11 +78,7 @@ public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() $this->mockRequest->withHeader('authorization', 'Bearer ') ->willReturn($this->mockRequest->reveal()); - // Run the test. - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($this->mockFetcher->reveal()); } public function testUsesIdTokenWhenAccessTokenDoesNotExist() @@ -96,12 +90,10 @@ public function testUsesIdTokenWhenAccessTokenDoesNotExist() ->willReturn($authResult); $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) ->shouldBeCalledTimes(1) - ->willReturn($this->mockRequest); + ->willReturn($this->mockRequest->reveal()); + + $this->runTestCase($this->mockFetcher->reveal()); - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); } public function testUsesCachedAccessToken() @@ -133,10 +125,7 @@ public function testUsesCachedAccessToken() null, $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testUsesCachedIdToken() @@ -168,10 +157,7 @@ public function testUsesCachedIdToken() null, $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testGetsCachedAuthTokenUsingCacheOptions() @@ -204,10 +190,7 @@ public function testGetsCachedAuthTokenUsingCacheOptions() ['prefix' => $prefix], $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testShouldSaveValueInCacheWithSpecifiedPrefix() @@ -248,10 +231,7 @@ public function testShouldSaveValueInCacheWithSpecifiedPrefix() ['prefix' => $prefix, 'lifetime' => $lifetime], $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } /** @@ -282,6 +262,8 @@ public function testShouldNotifyTokenCallback(callable $tokenCallback) $this->mockFetcher->fetchAuthToken(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn($cachedValue); + $this->mockFetcher->getLastReceivedToken() + ->willReturn($cachedValue); $this->mockRequest->withHeader(Argument::any(), Argument::any()) ->willReturn($this->mockRequest->reveal()); @@ -306,6 +288,71 @@ public function testShouldNotifyTokenCallback(callable $tokenCallback) $this->assertTrue(MiddlewareCallback::$called); } + public function testAddAuthHeadersFromUpdateMetadata() + { + $authResult = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + ]; + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $request = new Request('GET', 'http://foo.com'); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authResult, &$mockHandlerCalled) { + $this->assertEquals($authResult['authorization'], $request->getHeaderLine('authorization')); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + public function testOverlappingAddAuthHeadersFromUpdateMetadata() + { + $authHeaders = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + 'x-goog-api-client' => 'extra-value' + ]; + + $request = new Request('GET', 'http://foo.com'); + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authHeaders); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authHeaders, &$mockHandlerCalled) { + $this->assertEquals($authHeaders['authorization'], $request->getHeaderLine('authorization')); + $this->assertArrayHasKey('x-goog-api-client', $request->getHeaders()); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + private function runTestCase($fetcher) + { + $middleware = new AuthTokenMiddleware($fetcher); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + } + public function provideShouldNotifyTokenCallback() { MiddlewareCallback::$phpunit = $this; diff --git a/tests/fixtures/private.json b/tests/fixtures/private.json index 5d6d1ea64..ef1d49507 100644 --- a/tests/fixtures/private.json +++ b/tests/fixtures/private.json @@ -4,5 +4,6 @@ "client_email": "hello@youarecool.com", "client_id": "client123", "type": "service_account", - "quota_project_id": "test_quota_project" + "quota_project_id": "test_quota_project", + "universe_domain": "example-universe.com" } diff --git a/tests/fixtures2/private.json b/tests/fixtures2/private.json index 20bb61793..9ae0aae96 100644 --- a/tests/fixtures2/private.json +++ b/tests/fixtures2/private.json @@ -3,5 +3,6 @@ "client_secret": "clientSecret123", "refresh_token": "refreshToken123", "type": "authorized_user", - "quota_project_id": "test_quota_project" + "quota_project_id": "test_quota_project", + "universe_domain": "example-universe.com" }