diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 18241670e..38982a9f2 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\Credentials\UserRefreshCredentials; use Google\Auth\HttpHandler\HttpClientCache; @@ -301,6 +302,7 @@ public static function getIdTokenCredentials( $creds = match ($jsonKey['type']) { 'authorized_user' => new UserRefreshCredentials(null, $jsonKey, $targetAudience), + 'impersonated_service_account' => new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience), 'service_account' => new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience), default => throw new InvalidArgumentException('invalid value in the type field') }; diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index b907d8d96..264e90020 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -35,6 +35,7 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements use IamSignerTrait; private const CRED_TYPE = 'imp'; + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; /** * @var string @@ -71,10 +72,12 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements * @type int $lifetime The lifetime of the impersonated credentials * @type string[] $delegates The delegates to impersonate * } + * @param string|null $targetAudience The audience to request an ID token. */ public function __construct( $scope, - $jsonKey + $jsonKey, + private ?string $targetAudience = null ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -93,10 +96,23 @@ public function __construct( if (!array_key_exists('source_credentials', $jsonKey)) { throw new LogicException('json key is missing the source_credentials field'); } + if ($scope && $targetAudience) { + throw new InvalidArgumentException( + 'Scope and targetAudience cannot both be supplied' + ); + } if (is_array($jsonKey['source_credentials'])) { if (!array_key_exists('type', $jsonKey['source_credentials'])) { throw new InvalidArgumentException('json key source credentials are missing the type field'); } + if ( + $targetAudience !== null + && $jsonKey['source_credentials']['type'] === 'service_account' + ) { + // Service account tokens MUST request a scope, and as this token is only used to impersonate + // an ID token, the narrowest scope we can request is `iam`. + $scope = self::IAM_SCOPE; + } $jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']); } @@ -171,13 +187,19 @@ public function fetchAuthToken(?callable $httpHandler = null) 'Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), - ], 'at'); - - $body = [ - 'scope' => $this->targetScope, - 'delegates' => $this->delegates, - 'lifetime' => sprintf('%ss', $this->lifetime), - ]; + ], $this->isIdTokenRequest() ? 'it' : 'at'); + + $body = match ($this->isIdTokenRequest()) { + true => [ + 'audience' => $this->targetAudience, + 'includeEmail' => true, + ], + false => [ + 'scope' => $this->targetScope, + 'delegates' => $this->delegates, + 'lifetime' => sprintf('%ss', $this->lifetime), + ] + }; $request = new Request( 'POST', @@ -189,10 +211,13 @@ public function fetchAuthToken(?callable $httpHandler = null) $response = $httpHandler($request); $body = json_decode((string) $response->getBody(), true); - return [ - 'access_token' => $body['accessToken'], - 'expires_at' => strtotime($body['expireTime']), - ]; + return match ($this->isIdTokenRequest()) { + true => ['id_token' => $body['token']], + false => [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ] + }; } /** @@ -220,4 +245,9 @@ protected function getCredType(): string { return self::CRED_TYPE; } + + private function isIdTokenRequest(): bool + { + return !is_null($this->targetAudience); + } } diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index 1e8a378ef..230d2a47f 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -496,6 +496,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound() ); } + public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials() + { + putenv('HOME=' . __DIR__ . '/fixtures5'); + $creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com'); + $this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds); + } + public function testGetIdTokenCredentialsWithCacheOptions() { $keyFile = __DIR__ . '/fixtures' . '/private.json'; diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 5dca3666c..fe950156f 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -23,7 +23,9 @@ use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\OAuth2; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use LogicException; use PHPUnit\Framework\TestCase; @@ -133,6 +135,44 @@ public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($j $this->assertEquals(2, $requestCount); } + /** + * Test ID token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) + { + $requestCount = 0; + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $grantType) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri()); + parse_str((string) $request->getBody(), $result); + $this->assertEquals($grantType, $result['grant_type']); + } elseif ($requestCount == 2) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['token' => 'test-impersonated-id-token'] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals(2, $requestCount); + } + public function provideAuthTokenJson() { return [ @@ -180,6 +220,72 @@ public function testGetAccessTokenWithExternalAccountCredentials() $this->assertEquals(3, $requestCount); } + /** + * Test ID token impersonation for Exernal Account Credentials. + */ + public function testGetIdTokenWithExternalAccountCredentials() + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json) { + if (++$requestCount == 1) { + // the call to swap the refresh token for an access token + $this->assertEquals( + $json['source_credentials']['credential_source']['url'], + (string) $request->getUri() + ); + } elseif ($requestCount == 2) { + $this->assertEquals($json['source_credentials']['token_url'], (string) $request->getUri()); + } elseif ($requestCount == 3) { + // the call to swap the access token for an id token + $this->assertEquals($json['service_account_impersonation_url'], (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + } + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(match ($requestCount) { + 1 => ['access_token' => 'test-access-token'], + 2 => ['access_token' => 'test-access-token'], + 3 => ['token' => 'test-impersonated-id-token'] + }) + ); + }; + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + $this->assertEquals(3, $requestCount); + } + + /** + * Test ID token impersonation for an arbitrary credential fetcher. + */ + public function testGetIdTokenWithArbitraryCredentials() + { + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('https://some/url', (string) $request->getUri()); + $this->assertEquals('Bearer test-access-token', $request->getHeader('authorization')[0] ?? null); + return new Response(200, [], json_encode(['token' => 'test-impersonated-id-token'])); + }; + + $credentials = $this->prophesize(FetchAuthTokenInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url', + 'source_credentials' => $credentials->reveal(), + ]; + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + + $token = $creds->fetchAuthToken($httpHandler); + $this->assertEquals('test-impersonated-id-token', $token['id_token']); + } + /** * Test access token impersonation for an arbitrary credential fetcher. */ @@ -211,6 +317,34 @@ public function testGetAccessTokenWithArbitraryCredentials() $this->assertEquals('test-impersonated-access-token', $token['access_token']); } + public function testIdTokenWithAuthTokenMiddleware() + { + $targetAudience = 'test-target-audience'; + $credentials = new ImpersonatedServiceAccountCredentials(null, self::USER_TO_SERVICE_ACCOUNT_JSON, $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 + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + $requestCount++; + $this->assertTrue($request->hasHeader('authorization')); + $this->assertEquals('Bearer this.is.an.id.token', $request->getHeader('authorization')[0] ?? null); + }; + + $middleware($httpHandler)( + new Request('GET', 'https://www.google.com'), + ['auth' => 'google_auth'] + ); + + $this->assertEquals(1, $requestCount); + } + // User Refresh to Service Account Impersonation JSON Credentials private const USER_TO_SERVICE_ACCOUNT_JSON = [ 'type' => 'impersonated_service_account', diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php index f06b4f28e..c71e8746f 100644 --- a/tests/ObservabilityMetricsTest.php +++ b/tests/ObservabilityMetricsTest.php @@ -131,6 +131,20 @@ public function testImpersonatedServiceAccountCredentials() $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); } + public function testImpersonatedServiceAccountCredentialsWithIdTokens() + { + $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $responseFromIam = json_encode(['token' => '1/abdef1234567890']); + $handler = getHandler([ + $this->getExpectedRequest('imp', 'auth-request-type/at', $handlerCalled, $this->jsonTokens), + $this->getExpectedRequest('imp', 'auth-request-type/it', $handlerCalled, $responseFromIam), + ]); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials(null, $keyFile, 'test-target-audience'); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + /** * UserRefreshCredentials haven't enabled identity token support hence * they don't have 'auth-request-type/it' observability metric header check.