From c1b240f77e5d2b97c481c9d1f23bd57524a22553 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 1 Feb 2024 10:19:59 -0700 Subject: [PATCH] feat: workforce credentials (#485) --- .../ExternalAccountCredentials.php | 95 ++++++- src/FetchAuthTokenCache.php | 18 +- src/OAuth2.php | 13 + .../ExternalAccountCredentialsTest.php | 243 +++++++++++++++--- 4 files changed, 332 insertions(+), 37 deletions(-) diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index b2716bfaa..bc4a68610 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -23,23 +23,34 @@ use Google\Auth\ExternalAccountCredentialSourceInterface; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\GetQuotaProjectInterface; +use Google\Auth\GetUniverseDomainInterface; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\OAuth2; +use Google\Auth\ProjectIdProviderInterface; use Google\Auth\UpdateMetadataInterface; use Google\Auth\UpdateMetadataTrait; use GuzzleHttp\Psr7\Request; use InvalidArgumentException; -class ExternalAccountCredentials implements FetchAuthTokenInterface, UpdateMetadataInterface, GetQuotaProjectInterface +class ExternalAccountCredentials implements + FetchAuthTokenInterface, + UpdateMetadataInterface, + GetQuotaProjectInterface, + GetUniverseDomainInterface, + ProjectIdProviderInterface { use UpdateMetadataTrait; private const EXTERNAL_ACCOUNT_TYPE = 'external_account'; + private const CLOUD_RESOURCE_MANAGER_URL='https://cloudresourcemanager.UNIVERSE_DOMAIN/v1/projects/%s'; private OAuth2 $auth; private ?string $quotaProject; private ?string $serviceAccountImpersonationUrl; + private ?string $workforcePoolUserProject; + private ?string $projectId; + private string $universeDomain; /** * @param string|string[] $scope The scope of the access request, expressed either as an array @@ -90,6 +101,8 @@ 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->auth = new OAuth2([ 'tokenCredentialUri' => $jsonKey['token_url'], @@ -97,7 +110,16 @@ public function __construct( 'scope' => $scope, 'subjectTokenType' => $jsonKey['subject_token_type'], 'subjectTokenFetcher' => self::buildCredentialSource($jsonKey), + 'additionalOptions' => $this->workforcePoolUserProject + ? ['userProject' => $this->workforcePoolUserProject] + : [], ]); + + if (!$this->isWorkforcePool() && $this->workforcePoolUserProject) { + throw new InvalidArgumentException( + 'workforce_pool_user_project should not be set for non-workforce pool credentials.' + ); + } } /** @@ -154,6 +176,7 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr throw new InvalidArgumentException('Unable to determine credential source from json key.'); } + /** * @param string $stsToken * @param callable $httpHandler @@ -181,7 +204,7 @@ private function getImpersonatedAccessToken(string $stsToken, callable $httpHand ], (string) json_encode([ 'lifetime' => sprintf('%ss', OAuth2::DEFAULT_EXPIRY_SECONDS), - 'scope' => $this->auth->getScope(), + 'scope' => explode(' ', $this->auth->getScope()), ]), ); if (is_null($httpHandler)) { @@ -190,8 +213,8 @@ private function getImpersonatedAccessToken(string $stsToken, callable $httpHand $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); return [ - 'access_token' => $body['accessToken'], - 'expires_at' => strtotime($body['expireTime']), + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), ]; } @@ -238,4 +261,68 @@ public function getQuotaProject() { return $this->quotaProject; } + + /** + * Get the universe domain used for this API request + * + * @return string + */ + public function getUniverseDomain(): string + { + return $this->universeDomain; + } + + /** + * Get the project ID. + * + * @param callable $httpHandler Callback which delivers psr7 request + * @param string $accessToken The access token to use to sign the blob. If + * provided, saves a call to the metadata server for a new access + * token. **Defaults to** `null`. + * @return string|null + */ + public function getProjectId(callable $httpHandler = null, string $accessToken = null) + { + if (isset($this->projectId)) { + return $this->projectId; + } + + $projectNumber = $this->getProjectNumber() ?: $this->workforcePoolUserProject; + if (!$projectNumber) { + return null; + } + + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $url = str_replace( + 'UNIVERSE_DOMAIN', + $this->getUniverseDomain(), + sprintf(self::CLOUD_RESOURCE_MANAGER_URL, $projectNumber) + ); + + if (is_null($accessToken)) { + $accessToken = $this->fetchAuthToken($httpHandler)['access_token']; + } + + $request = new Request('GET', $url, ['authorization' => 'Bearer ' . $accessToken]); + $response = $httpHandler($request); + + $body = json_decode((string) $response->getBody(), true); + return $this->projectId = $body['projectId']; + } + + private function getProjectNumber(): ?string + { + $parts = explode('/', $this->auth->getAudience()); + $i = array_search('projects', $parts); + return $parts[$i + 1] ?? null; + } + + private function isWorkforcePool(): bool + { + $regex = '#//iam\.googleapis\.com/locations/[^/]+/workforcePools/#'; + return preg_match($regex, $this->auth->getAudience()) === 1; + } } diff --git a/src/FetchAuthTokenCache.php b/src/FetchAuthTokenCache.php index a76c4f601..63f0c827b 100644 --- a/src/FetchAuthTokenCache.php +++ b/src/FetchAuthTokenCache.php @@ -146,9 +146,12 @@ public function signBlob($stringToSign, $forceOpenSsl = false) ); } - // Pass the access token from cache to GCECredentials for signing a blob. - // This saves a call to the metadata server when a cached token exists. - if ($this->fetcher instanceof Credentials\GCECredentials) { + // Pass the access token from cache for credentials that sign blobs + // using the IAM API. This saves a call to fetch an access token when a + // cached token exists. + if ($this->fetcher instanceof Credentials\GCECredentials + || $this->fetcher instanceof Credentials\ImpersonatedServiceAccountCredentials + ) { $cached = $this->fetchAuthTokenFromCache(); $accessToken = $cached['access_token'] ?? null; return $this->fetcher->signBlob($stringToSign, $forceOpenSsl, $accessToken); @@ -189,6 +192,15 @@ public function getProjectId(callable $httpHandler = null) ); } + // Pass the access token from cache for credentials that require an + // access token to fetch the project ID. This saves a call to fetch an + // access token when a cached token exists. + if ($this->fetcher instanceof Credentials\ExternalAccountCredentials) { + $cached = $this->fetchAuthTokenFromCache(); + $accessToken = $cached['access_token'] ?? null; + return $this->fetcher->getProjectId($httpHandler, $accessToken); + } + return $this->fetcher->getProjectId($httpHandler); } diff --git a/src/OAuth2.php b/src/OAuth2.php index 2e5adcdcf..5fc3ba80c 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -321,6 +321,14 @@ class OAuth2 implements FetchAuthTokenInterface */ private ?string $issuedTokenType = null; + /** + * From STS response. + * An identifier for the representation of the issued security token. + * + * @var array + */ + private array $additionalOptions; + /** * Create a new OAuthCredentials. * @@ -438,6 +446,7 @@ public function __construct(array $config) 'subjectTokenType' => null, 'actorToken' => null, 'actorTokenType' => null, + 'additionalOptions' => [], ], $config); $this->setAuthorizationUri($opts['authorizationUri']); @@ -466,6 +475,7 @@ public function __construct(array $config) $this->subjectTokenType = $opts['subjectTokenType']; $this->actorToken = $opts['actorToken']; $this->actorTokenType = $opts['actorTokenType']; + $this->additionalOptions = $opts['additionalOptions']; $this->updateToken($opts); } @@ -616,6 +626,9 @@ public function generateCredentialsRequest(callable $httpHandler = null) 'actor_token' => $this->actorToken, 'actor_token_type' => $this->actorTokenType, ]); + if ($this->additionalOptions) { + $params['options'] = json_encode($this->additionalOptions); + } break; default: if (!is_null($this->getRedirectUri())) { diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php index 39fe46045..657dbe711 100644 --- a/tests/Credentials/ExternalAccountCredentialsTest.php +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -21,9 +21,12 @@ use Google\Auth\CredentialSource\AwsNativeSource; use Google\Auth\CredentialSource\FileSource; use Google\Auth\CredentialSource\UrlSource; +use Google\Auth\FetchAuthTokenCache; +use Google\Auth\GetUniverseDomainInterface; use Google\Auth\OAuth2; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -36,6 +39,13 @@ class ExternalAccountCredentialsTest extends TestCase { use ProphecyTrait; + private $baseCreds = [ + 'type' => 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['url' => 'sts-url.com'], + ]; /** * @dataProvider provideCredentialSourceFromCredentials @@ -46,12 +56,8 @@ public function testCredentialSourceFromCredentials( array $expectedProperties = [] ) { $jsonCreds = [ - 'type' => 'external_account', - 'token_url' => '', - 'audience' => '', - 'subject_token_type' => '', 'credential_source' => $credentialSource, - ]; + ] + $this->baseCreds; $credsReflection = new \ReflectionClass(ExternalAccountCredentials::class); $credsProp = $credsReflection->getProperty('auth'); @@ -199,12 +205,8 @@ public function testFetchAuthTokenFileCredentials() file_put_contents($tmpFile, 'abc'); $jsonCreds = [ - 'type' => 'external_account', - 'token_url' => 'token-url.com', - 'audience' => '', - 'subject_token_type' => '', 'credential_source' => ['file' => $tmpFile], - ]; + ] + $this->baseCreds; $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); @@ -230,15 +232,7 @@ public function testFetchAuthTokenFileCredentials() public function testFetchAuthTokenUrlCredentials() { - $jsonCreds = [ - 'type' => 'external_account', - 'token_url' => 'token-url.com', - 'audience' => '', - 'subject_token_type' => '', - 'credential_source' => ['url' => 'sts-url.com'], - ]; - - $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $creds = new ExternalAccountCredentials('a-scope', $this->baseCreds); $requestCount = 0; $httpHandler = function (RequestInterface $request) use (&$requestCount) { @@ -279,13 +273,9 @@ public function testFetchAuthTokenWithImpersonation() file_put_contents($tmpFile, 'abc'); $jsonCreds = [ - 'type' => 'external_account', - 'token_url' => 'token-url.com', - 'audience' => '', - 'subject_token_type' => '', 'credential_source' => ['file' => $tmpFile], 'service_account_impersonation_url' => 'service-account-impersonation-url.com', - ]; + ] + $this->baseCreds; $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); @@ -301,6 +291,8 @@ public function testFetchAuthTokenWithImpersonation() break; case 2: $this->assertEquals('service-account-impersonation-url.com', (string) $request->getUri()); + $requestBody = json_decode((string) $request->getBody(), true); + $this->assertEquals(['a-scope'], $requestBody['scope']); $responseBody = json_encode(['accessToken' => 'def', 'expireTime' => $expiry]); break; } @@ -326,15 +318,206 @@ public function testFetchAuthTokenWithImpersonation() public function testGetQuotaProject() { $jsonCreds = [ - 'type' => 'external_account', - 'token_url' => 'token-url.com', - 'audience' => '', - 'subject_token_type' => '', - 'credential_source' => ['url' => 'sts-url.com'], + 'quota_project_id' => 'test_quota_project', - ]; + ] + $this->baseCreds; $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); $this->assertEquals('test_quota_project', $creds->getQuotaProject()); } + + /** + * Test the getProjectId method, which makes an API call using the project number in order to + * retrieve the project ID. + * + * @dataProvider provideGetProjectId + */ + public function testGetProjectId(array $jsonCreds, string $expectedProjectNumber) + { + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expectedProjectNumber) { + switch (++$requestCount) { + case 1: + $this->assertEquals('sts-url.com', (string) $request->getUri()); + $responseBody = 'abc'; + break; + case 2: + $this->assertEquals('token-url.com', (string) $request->getUri()); + $responseBody = '{"access_token": "def"}'; + break; + case 3: + $this->assertEquals( + 'https://cloudresourcemanager.googleapis.com/v1/projects/' . $expectedProjectNumber, + (string) $request->getUri() + ); + $responseBody = json_encode(['projectId' => 'test-project-id']); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals('test-project-id', $creds->getProjectId($httpHandler)); + } + + public function provideGetProjectId() + { + return [ + // from audience + [ + [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/workloadIdentityPools/foo/providers/bar', + ] + $this->baseCreds, + '1234' + ], + // from workforce_pool_user_project + [ + [ + 'audience' => '//iam.googleapis.com/locations/global/workforcePools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + ] + $this->baseCreds, + '4567' + ], + ]; + } + + /** + * the getProjectId method makes an API call using the project number in order to retrieve the + * project ID. Test that a cached access token is used for the API call to fetch the projectId, + * instead of retrieving a new one. + */ + public function testCacheIsCalledForGetProjectIdWithCache() + { + $jsonCreds = [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/workloadIdentityPools/foo/providers/bar', + ] + $this->baseCreds; + + $httpHandler = function (RequestInterface $request) { + $this->assertEquals( + 'https://cloudresourcemanager.googleapis.com/v1/projects/1234', + (string) $request->getUri() + ); + $this->assertEquals('Bearer some-token', $request->getHeaderLine('authorization')); + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode(['projectId' => 'test-project-id'])); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $mockCacheItem = $this->prophesize('Psr\Cache\CacheItemInterface'); + $mockCacheItem->isHit() + ->shouldBeCalledTimes(1) + ->willReturn(true); + $mockCacheItem->get() + ->shouldBeCalledTimes(1) + ->willReturn(['access_token' => 'some-token']); + $mockCache = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $mockCache->getItem(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($mockCacheItem->reveal()); + + // Run the test + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + // Verify the cache passed to the wrapping Fetcher is never called + $cachedFetcher = new FetchAuthTokenCache( + $creds, + [], + $mockCache->reveal() + ); + + $this->assertEquals('test-project-id', $cachedFetcher->getProjectId($httpHandler)); + } + + public function testGetUniverseDomain() + { + // no universe domain is the default "googleapis.com" + $creds = new ExternalAccountCredentials('a-scope', $this->baseCreds); + $this->assertEquals( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $creds->getUniverseDomain() + ); + + // universe domain in credentials is used if supplied + $universeDomain = 'example-universe.com'; + $jsonCreds = [ + 'universe_domain' => $universeDomain, + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals($universeDomain, $creds->getUniverseDomain()); + } + + public function testWorkforcePoolWithNonWorkforceAudienceThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('workforce_pool_user_project should not be set for non-workforce pool credentials.'); + + $jsonCreds = [ + 'audience' => '//iam.googleapis.com/projects/1234/locations/global/workloadIdentityPools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + ] + $this->baseCreds; + new ExternalAccountCredentials('a-scope', $jsonCreds); + } + + public function testFetchAuthTokenWithWorkforcePoolCredentials() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'credential_source' => ['file' => $tmpFile], + 'audience' => '//iam.googleapis.com/locations/global/workforcePools/foo/providers/bar', + 'workforce_pool_user_project' => '4567', + 'service_account_impersonation_url' => 'service-account-impersonation-url.com', + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $requestCount = 0; + $expiry = '2023-10-05T18:00:01Z'; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expiry) { + switch (++$requestCount) { + case 1: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $this->assertEquals('{"userProject":"4567"}', $requestBody['options']); + $responseBody = '{"access_token": "def"}'; + break; + case 2: + $this->assertEquals('service-account-impersonation-url.com', (string) $request->getUri()); + $responseBody = json_encode(['accessToken' => 'def', 'expireTime' => $expiry]); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 1) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + $this->assertEquals(strtotime($expiry), $authToken['expires_at']); + } }