From 48554e66ed3317e3308c508545e4b5905274b538 Mon Sep 17 00:00:00 2001 From: Gerrit Jan van Ahee Date: Fri, 20 Sep 2024 13:05:48 +0200 Subject: [PATCH] feat: Allow Impersonated SA Credentials to make requests authenticated with an IdToken feat: Allow Impersonated SA Credentials to make requests authenticated with an IdToken chore: Removed uncommented code --- src/ApplicationDefaultCredentials.php | 7 +- .../ImpersonatedServiceAccountCredentials.php | 90 +++++++++++++++--- src/Middleware/AuthTokenMiddleware.php | 2 + tests/ApplicationDefaultCredentialsTest.php | 8 ++ ...ersonatedServiceAccountCredentialsTest.php | 93 +++++++++++++++++-- tests/FetchAuthTokenTest.php | 1 + ...ersonated_service_account_credentials.json | 10 ++ 7 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures3/impersonated_service_account_credentials.json diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 80437c8c9..b43b06e6c 100644 --- a/src/ApplicationDefaultCredentials.php +++ b/src/ApplicationDefaultCredentials.php @@ -20,6 +20,7 @@ use DomainException; use Google\Auth\Credentials\AppIdentityCredentials; use Google\Auth\Credentials\GCECredentials; +use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; @@ -302,11 +303,13 @@ public static function getIdTokenCredentials( throw new InvalidArgumentException('ID tokens are not supported for end user credentials'); } - if ($jsonKey['type'] != 'service_account') { + if (! in_array($jsonKey['type'], ['service_account', 'impersonated_service_account'])) { throw new InvalidArgumentException('invalid value in the type field'); } - $creds = new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience); + $creds = $jsonKey['type'] === 'service_account' + ? new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience) + : new ImpersonatedServiceAccountCredentials(null, $jsonKey, null, $targetAudience); } elseif (self::onGce($httpHandler, $cacheConfig, $cache)) { $creds = new GCECredentials(null, null, $targetAudience); $creds->setIsOnGce(true); // save the credentials a trip to the metadata server diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 5d3522827..917342978 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -18,9 +18,16 @@ namespace Google\Auth\Credentials; +use Exception; use Google\Auth\CredentialsLoader; +use Google\Auth\HttpHandler\HttpClientCache; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; +use Google\Auth\OAuth2; use Google\Auth\SignBlobInterface; +use GuzzleHttp\Psr7\Request; +use InvalidArgumentException; +use Psr\Http\Message\RequestInterface; class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface { @@ -31,25 +38,37 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements /** * @var string */ - protected $impersonatedServiceAccountName; + protected string $impersonatedServiceAccountName; /** * @var UserRefreshCredentials */ - protected $sourceCredentials; + protected UserRefreshCredentials $sourceCredentials; + + /** + * @var array{target_audience?: string} Additional claims for the id token + */ + protected array $additionalClaims; /** * Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that * has be created with the --impersonated-service-account flag. * - * @param string|string[] $scope The scope of the access request, expressed either as an - * array or as a space-delimited string. + * @param string|string[]|null $scope The scope of the access request, expressed either as an + * array or as a space-delimited string. * @param string|array $jsonKey JSON credential file path or JSON credentials - * as an associative array. + * as an associative array. + * @param string|null $sub an email address account to impersonate, in situations when + * the service account has been delegated domain wide access. + * @param string|null $targetAudience The audience for the ID token. */ public function __construct( - $scope, - $jsonKey + string|array|null $scope, + string|array $jsonKey, + // sub is currently not implemented but specified to keep the order of arguments + // the same as ServiceAccountCredentials + string $sub = null, + string $targetAudience = null ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -69,10 +88,21 @@ public function __construct( throw new \LogicException('json key is missing the source_credentials field'); } + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } + $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl( $jsonKey['service_account_impersonation_url'] ); + $this->additionalClaims = []; + if ($targetAudience) { + $this->additionalClaims = ['target_audience' => $targetAudience]; + } + $this->sourceCredentials = new UserRefreshCredentials( $scope, $jsonKey['source_credentials'] @@ -109,8 +139,9 @@ public function getClientName(callable $unusedHttpHandler = null) } /** - * @param callable $httpHandler + * Get an auth token. * + * @param callable $httpHandler * @return array { * A set of auth related metadata, containing the following * @@ -118,16 +149,51 @@ public function getClientName(callable $unusedHttpHandler = null) * @type int $expires_in * @type string $scope * @type string $token_type - * @type string $id_token + * @type ?string $id_token * } + * @throws Exception */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null): array { - // We don't support id token endpoint requests as of now for Impersonated Cred - return $this->sourceCredentials->fetchAuthToken( + $tokens = $this->sourceCredentials->fetchAuthToken( $httpHandler, $this->applyTokenEndpointMetrics([], 'at') ); + + // the authRequestType='it' does not work + // fetch an id token using the access token from iam credentials + if (array_key_exists('target_audience', $this->additionalClaims)) { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $impersonatedServiceAccount = $this->getClientName(); + $request = new Request( + 'POST', + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{$impersonatedServiceAccount}:generateIdToken", + [ + 'Authorization' => "Bearer {$tokens['access_token']}", + 'Cache-Control' => 'no-store', + 'Content-Type' => 'application/json', + ], + json_encode([ + 'audience' => $this->additionalClaims['target_audience'], + 'includeEmail' => true, + ]) + ); + $body = (string) $httpHandler($request)->getBody(); + + // Assume it's JSON; if it's not throw an exception + if (null === $res = json_decode($body, true)) { + throw new Exception('Invalid JSON response'); + } + // we cannot append the id_token to the list of tokens already fetched + // as the AuthTokenMiddleware will first try to set the access_token if + // it can find it. + $tokens = ['id_token' => $res['token']]; + } + + return $tokens; } /** diff --git a/src/Middleware/AuthTokenMiddleware.php b/src/Middleware/AuthTokenMiddleware.php index 798766efa..8bf5e011b 100644 --- a/src/Middleware/AuthTokenMiddleware.php +++ b/src/Middleware/AuthTokenMiddleware.php @@ -17,6 +17,7 @@ namespace Google\Auth\Middleware; +use Exception; use Google\Auth\FetchAuthTokenCache; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\GetQuotaProjectInterface; @@ -123,6 +124,7 @@ public function __invoke(callable $handler) * * @param RequestInterface $request * @return RequestInterface + * @throws Exception */ private function addAuthHeaders(RequestInterface $request) { diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index fa537691f..b8a1d6a43 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -21,6 +21,7 @@ use Google\Auth\ApplicationDefaultCredentials; use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\Credentials\GCECredentials; +use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\CredentialSource; @@ -155,6 +156,13 @@ public function testGceCredentials() $this->assertStringContainsString('a+user+scope', $tokenUri); } + public function testGetIdTokenCredentialsCanFindImpersonatedServiceAccountCredentials() + { + putenv('GOOGLE_APPLICATION_CREDENTIALS=' . __DIR__ . '/fixtures3/impersonated_service_account_credentials.json'); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com'); + $this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds); + } + public function testImpersonatedServiceAccountCredentials() { putenv('HOME=' . __DIR__ . '/fixtures5'); diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 9eeb418cb..306a342b7 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -19,24 +19,25 @@ namespace Google\Auth\Tests\Credentials; use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials; +use Google\Auth\Credentials\UserRefreshCredentials; +use Google\Auth\Middleware\AuthTokenMiddleware; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; use LogicException; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; class ImpersonatedServiceAccountCredentialsTest extends TestCase { + use ProphecyTrait; + // Creates a standard JSON auth object for testing. private function createISACTestJson() { - return [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken', - 'source_credentials' => [ - 'client_id' => 'client123', - 'client_secret' => 'clientSecret123', - 'refresh_token' => 'refreshToken123', - 'type' => 'authorized_user', - ] - ]; + return json_decode(file_get_contents(__DIR__ . '/../fixtures3/impersonated_service_account_credentials.json'), true); } public function testGetServiceAccountNameEmail() @@ -69,4 +70,76 @@ public function testErrorCredentials() $this->expectException(LogicException::class); new ImpersonatedServiceAccountCredentials($scope, $testJson['source_credentials']); } + + public function testGetIdToken() + { + $testJson = $this->createISACTestJson(); + $targetAudience = '123@456.com'; + $creds = new ImpersonatedServiceAccountCredentials(null, $testJson, null, $targetAudience); + + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $creds) { + $impersonatedServiceAccount = $creds->getClientName(); + + $responseBody = ''; + switch (++$requestCount) { + case 1: // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + $responseBody = '{"access_token":"this is an access token"}'; + break; + + case 2: // the call to swap the access token for an id token + $this->assertEquals("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{$impersonatedServiceAccount}:generateIdToken", (string) $request->getUri()); + $authHeader = $request->getHeader('authorization'); + $this->assertCount(1, $authHeader); + $this->assertEquals('Bearer this is an access token', $authHeader[0]); + $responseBody = '{"token": "this is the id token"}'; + 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(true); + $response->getHeaderLine('Content-Type')->willReturn('application/json'); + + if ($requestCount === 2) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $creds->fetchAuthToken($httpHandler); + // any checks on the result are futile as they have been coded above + } + public function testCanBeUsedInAuthTokenMiddlewareWhenAnAudienceIsGiven() + { + $targetAudience = '123@456.com'; + $jsonKey = $this->createISACTestJson(); + $credentials = new ImpersonatedServiceAccountCredentials(null, $jsonKey, null, $targetAudience); + + // this handler is for the middleware constructor, which will pass it to the ISAC to fetch tokens + $httpHandler = getHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{"access_token":"this.is.an.access.token"}'), + new Response(200, ['Content-Type' => 'application/json'], '{"token":"this.is.an.id.token"}'), + ]); + $middleware = new AuthTokenMiddleware($credentials, $httpHandler); + + // this handler is the actual handler that makes the authenticated request + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + $this->assertTrue($request->hasHeader('authorization')); + $authHeader = $request->getHeader('authorization'); + $this->assertCount(1, $authHeader); + $this->assertEquals('Bearer this.is.an.id.token', $authHeader[0]); + }; + + $middleware($httpHandler)( + new Request('GET', 'https://www.google.com'), + ['auth' => 'google_auth'] + ); + } } diff --git a/tests/FetchAuthTokenTest.php b/tests/FetchAuthTokenTest.php index 433dbe851..10a12fae2 100644 --- a/tests/FetchAuthTokenTest.php +++ b/tests/FetchAuthTokenTest.php @@ -109,6 +109,7 @@ public function provideMakeHttpClient() ['Google\Auth\Credentials\AppIdentityCredentials'], ['Google\Auth\Credentials\GCECredentials'], ['Google\Auth\Credentials\ServiceAccountCredentials'], + ['Google\Auth\Credentials\ImpersonatedServiceAccountCredentials'], ['Google\Auth\Credentials\ServiceAccountJwtAccessCredentials'], ['Google\Auth\Credentials\UserRefreshCredentials'], ['Google\Auth\OAuth2'], diff --git a/tests/fixtures3/impersonated_service_account_credentials.json b/tests/fixtures3/impersonated_service_account_credentials.json new file mode 100644 index 000000000..0f16d1193 --- /dev/null +++ b/tests/fixtures3/impersonated_service_account_credentials.json @@ -0,0 +1,10 @@ +{ + "type": "impersonated_service_account", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "client_id": "client123", + "client_secret": "clientSecret123", + "refresh_token": "refreshToken123", + "type": "authorized_user" + } +}