From 66db27c671c229ff561ecab51e0b6379c6109b93 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 12 Feb 2025 14:07:44 -0800 Subject: [PATCH 1/2] feat: add support for Impersonating ID Tokens (#580) --- src/ApplicationDefaultCredentials.php | 2 + .../ImpersonatedServiceAccountCredentials.php | 85 ++++- tests/ApplicationDefaultCredentialsTest.php | 20 +- ...ersonatedServiceAccountCredentialsTest.php | 316 +++++++++++++++--- tests/ObservabilityMetricsTest.php | 14 + 5 files changed, 373 insertions(+), 64 deletions(-) diff --git a/src/ApplicationDefaultCredentials.php b/src/ApplicationDefaultCredentials.php index 39107c7900..a64af46a94 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; @@ -307,6 +308,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 b907d8d969..8842cd17c9 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -21,6 +21,7 @@ use Google\Auth\CacheTrait; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\GetUniverseDomainInterface; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\IamSignerTrait; @@ -29,12 +30,17 @@ use InvalidArgumentException; use LogicException; -class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface +class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements + SignBlobInterface, + GetUniverseDomainInterface { use CacheTrait; use IamSignerTrait; private const CRED_TYPE = 'imp'; + private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam'; + private const ID_TOKEN_IMPERSONATION_URL = + 'https://iamcredentials.UNIVERSE_DOMAIN/v1/projects/-/serviceAccounts/%s:generateIdToken'; /** * @var string @@ -71,10 +77,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 + string|array|null $scope, + string|array $jsonKey, + private ?string $targetAudience = null ) { if (is_string($jsonKey)) { if (!file_exists($jsonKey)) { @@ -93,10 +101,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,17 +192,38 @@ 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'); + ], $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), + ] + }; - $body = [ - 'scope' => $this->targetScope, - 'delegates' => $this->delegates, - 'lifetime' => sprintf('%ss', $this->lifetime), - ]; + $url = $this->serviceAccountImpersonationUrl; + if ($this->isIdTokenRequest()) { + $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; + if (!preg_match($regex, $url, $matches)) { + throw new InvalidArgumentException( + 'Invalid service account impersonation URL - unable to parse service account email' + ); + } + $url = str_replace( + 'UNIVERSE_DOMAIN', + $this->getUniverseDomain(), + sprintf(self::ID_TOKEN_IMPERSONATION_URL, $matches['email']) + ); + } $request = new Request( 'POST', - $this->serviceAccountImpersonationUrl, + $url, $headers, (string) json_encode($body) ); @@ -189,10 +231,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 +265,16 @@ protected function getCredType(): string { return self::CRED_TYPE; } + + private function isIdTokenRequest(): bool + { + return !is_null($this->targetAudience); + } + + public function getUniverseDomain(): string + { + return $this->sourceCredentials instanceof GetUniverseDomainInterface + ? $this->sourceCredentials->getUniverseDomain() + : self::DEFAULT_UNIVERSE_DOMAIN; + } } diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index cf6b542d57..1db9b9e43e 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -32,7 +32,6 @@ use GuzzleHttp\Psr7; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; -use PHPUnit\Framework\Error\Notice; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Cache\CacheItemPoolInterface; @@ -499,6 +498,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'; @@ -803,10 +809,16 @@ public function testGetDefaultLoggerReturnsNullIfNotEnvVar() public function testGetDefaultLoggerRaiseAWarningIfMisconfiguredAndReturnsNull() { putenv($this::SDK_DEBUG_ENV_VAR . '=invalid'); - $this->expectException(Notice::class); - $logger = ApplicationDefaultCredentials::getDefaultLogger(); - $this->assertNull($logger); + $this->expectExceptionMessage( + 'The GOOGLE_SDK_PHP_LOGGING is set, but it is set to another value than false or true' + ); + + set_error_handler(static function (int $errno, string $errstr): never { + throw new \Exception($errstr, $errno); + }, E_USER_NOTICE); + + ApplicationDefaultCredentials::getDefaultLogger(); } public function provideExternalAccountCredentials() diff --git a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php index 5dca3666c5..94e82e7143 100644 --- a/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php +++ b/tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php @@ -23,8 +23,12 @@ use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\Credentials\UserRefreshCredentials; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\GetUniverseDomainInterface; +use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\OAuth2; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use InvalidArgumentException; use LogicException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -38,7 +42,46 @@ class ImpersonatedServiceAccountCredentialsTest extends TestCase private const SCOPE = ['scope/1', 'scope/2']; private const TARGET_AUDIENCE = 'test-target-audience'; - private const IMPERSONATION_URL = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateToken'; + private const IMPERSONATION_URL = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken'; + private const UNIVERSE_DOMAIN = 'example.com'; + + // User Refresh to Service Account Impersonation JSON Credentials + private const USER_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'client_id' => 'client123', + 'client_secret' => 'clientSecret123', + 'refresh_token' => 'refreshToken123', + 'type' => 'authorized_user', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'client_email' => 'clientemail@clientemail.com', + 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWgIBAAKBgGhw1WMos5gp2YjV7+fNwXN1tI4/DFXKzwY6TDWsPxkbyfjHgunX\n/sijlnJt3Qs1gBxiwEEjzFFlp39O3/gEbIoYWHR/4sZdqNRFzbhJcTpnUvRlZDBL\nE5h8f5uu4aL4D32WyiELF/vpr533lZCBwWsnN3zIYJxThgRF9i/R7F8tAgMBAAEC\ngYAgUyv4cNSFOA64J18FY82IKtojXKg4tXi1+L01r4YoA03TzgxazBtzhg4+hHpx\nybFJF9dhUe8fElNxN7xiSxw8i5MnfPl+piwbfoENhgrzU0/N14AV/4Pq+WAJQe2M\nxPcI1DPYMEwGjX2PmxqnkC47MyR9agX21YZVc9rpRCgPgQJBALodH492I0ydvEUs\ngT+3DkNqoWx3O3vut7a0+6k+RkM1Yu+hGI8RQDCGwcGhQlOpqJkYGsVegZbxT+AF\nvvIFrIUCQQCPqJbRalHK/QnVj4uovj6JvjTkqFSugfztB4Zm/BPT2eEpjLt+851d\nIJ4brK/HVkQT2zk9eb0YzIBfeQi9WpyJAkB9+BRSf72or+KsV1EsFPScgOG9jn4+\nhfbmvVzQ0ouwFcRfOQRsYVq2/Z7LNiC0i9LHvF7yU+MWjUJo+LqjCWAZAkBHearo\nMIzXgQRGlC/5WgZFhDRO3A2d8aDE0eymCp9W1V24zYNwC4dtEVB5Fncyp5Ihiv40\nvwA9eWoZll+pzo55AkBMMdk95skWeaRv8T0G1duv5VQ7q4us2S2TKbEbC8j83BTP\nNefc3KEugylyAjx24ydxARZXznPi1SFeYVx1KCMZ\n-----END RSA PRIVATE KEY-----\n", + 'type' => 'service_account', + ] + ]; + + // Service Account to Service Account Impersonation JSON Credentials + private const EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => self::IMPERSONATION_URL, + 'source_credentials' => [ + 'type' => 'external_account', + 'audience' => 'some_audience', + 'subject_token_type' => 'access_token', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => [ + 'url' => 'https://some.url/token' + ] + ] + ]; public function testGetServiceAccountNameEmail() { @@ -50,7 +93,7 @@ public function testGetServiceAccountNameEmail() public function testGetServiceAccountNameID() { $json = self::USER_TO_SERVICE_ACCOUNT_JSON; - $json['service_account_impersonation_url'] = 'https://some/arbitrary/url/1234567890987654321:generateAccessToken'; + $json['service_account_impersonation_url'] = 'https://some/arbitrary/url/serviceAccounts/1234567890987654321:generateAccessToken'; $creds = new ImpersonatedServiceAccountCredentials(self::SCOPE, $json); $this->assertEquals('1234567890987654321', $creds->getClientName()); } @@ -100,7 +143,7 @@ public function provideSourceCredentialsClass() * * @dataProvider provideAuthTokenJson */ - public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($json, $grantType) + public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials(array $json, string $grantType) { $requestCount = 0; // getting an id token will take two requests @@ -133,14 +176,6 @@ public function testGetAccessTokenWithServiceAccountAndUserRefreshCredentials($j $this->assertEquals(2, $requestCount); } - public function provideAuthTokenJson() - { - return [ - [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], - [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], - ]; - } - /** * Test access token impersonation for Exernal Account Credentials. */ @@ -180,6 +215,205 @@ public function testGetAccessTokenWithExternalAccountCredentials() $this->assertEquals(3, $requestCount); } + /** + * Test ID token impersonation for Service Account and User Refresh Credentials. + * + * @dataProvider provideAuthTokenJson + */ + public function testGetIdTokenWithServiceAccountAndUserRefreshCredentials(array $json, string $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( + str_replace(':generateAccessToken', ':generateIdToken', $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 [ + [self::USER_TO_SERVICE_ACCOUNT_JSON, 'refresh_token'], + [self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON, OAuth2::JWT_URN], + ]; + } + + /** + * Test ID token impersonation for Service Account Credentials with a universe domain. + */ + public function testGetIdTokenWithServiceAccountCredentialsAndUniverseDomain() + { + $json = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $json['source_credentials']['universe_domain'] = self::UNIVERSE_DOMAIN; + + // the expected URL should have the universe domain + $expectedUrl = str_replace( + ['googleapis.com', ':generateAccessToken'], + [self::UNIVERSE_DOMAIN, ':generateIdToken'], + $json['service_account_impersonation_url'], + ); + + // getting an id token will take two requests + $httpHandler = function (RequestInterface $request) use ($expectedUrl) { + $this->assertEquals($expectedUrl, (string) $request->getUri()); + $this->assertEquals(self::TARGET_AUDIENCE, json_decode($request->getBody(), true)['audience'] ?? ''); + $this->assertStringStartsWith('Bearer ', $request->getHeader('authorization')[0] ?? null); + + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['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']); + } + + /** + * Test invalid email throws exception + */ + public function testInvalidServiceAccountImpersonationUrlThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid service account impersonation URL - unable to parse service account email' + ); + + $json = self::SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + $json['service_account_impersonation_url'] = 'https://invalid/url'; + + // mock access token call for source credentials + $httpHandler = fn () => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['access_token' => 'test-access-token']) + ); + + $creds = new ImpersonatedServiceAccountCredentials(null, $json, self::TARGET_AUDIENCE); + $creds->fetchAuthToken($httpHandler); + } + + /** + * Test ID token impersonation for Exernal Account Credentials. + * @dataProvider provideUniverseDomain + */ + public function testGetIdTokenWithExternalAccountCredentials(?string $universeDomain = null) + { + $json = self::EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON; + if ($universeDomain) { + $json['source_credentials']['universe_domain'] = $universeDomain; + } + $httpHandler = function (RequestInterface $request) use (&$requestCount, $json, $universeDomain) { + 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 + $url = str_replace(':generateAccessToken', ':generateIdToken', $json['service_account_impersonation_url']); + if ($universeDomain) { + $url = str_replace('googleapis.com', $universeDomain, $url); + } + $this->assertEquals($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. + * @dataProvider provideUniverseDomain + */ + public function testGetIdTokenWithArbitraryCredentials(?string $universeDomain = null) + { + $url = $universeDomain + ? 'https://iamcredentials.' . self::UNIVERSE_DOMAIN . '/v1/projects/-/serviceAccounts/123:generateIdToken' + : 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/123:generateIdToken'; + + $httpHandler = function (RequestInterface $request) use ($url) { + // The URL is coerced to match the googleapis URL pattern + $this->assertEquals($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) + ->willImplement(GetUniverseDomainInterface::class); + $credentials->fetchAuthToken($httpHandler, Argument::type('array')) + ->shouldBeCalledOnce() + ->willReturn(['access_token' => 'test-access-token']); + $credentials->getUniverseDomain() + ->shouldBeCalledOnce() + ->willReturn($universeDomain ?: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN); + + $json = [ + 'type' => 'impersonated_service_account', + 'service_account_impersonation_url' => 'https://some/url/serviceAccounts/123:generateAccessToken', + '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']); + } + + public function provideUniverseDomain() + { + return [ + [null], + [self::UNIVERSE_DOMAIN], + ]; + } + /** * Test access token impersonation for an arbitrary credential fetcher. */ @@ -211,41 +445,31 @@ public function testGetAccessTokenWithArbitraryCredentials() $this->assertEquals('test-impersonated-access-token', $token['access_token']); } - // User Refresh to Service Account Impersonation JSON Credentials - private const USER_TO_SERVICE_ACCOUNT_JSON = [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => self::IMPERSONATION_URL, - 'source_credentials' => [ - 'client_id' => 'client123', - 'client_secret' => 'clientSecret123', - 'refresh_token' => 'refreshToken123', - 'type' => 'authorized_user', - ] - ]; + public function testIdTokenWithAuthTokenMiddleware() + { + $targetAudience = 'test-target-audience'; + $credentials = new ImpersonatedServiceAccountCredentials(null, self::USER_TO_SERVICE_ACCOUNT_JSON, $targetAudience); - // Service Account to Service Account Impersonation JSON Credentials - private const SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => self::IMPERSONATION_URL, - 'source_credentials' => [ - 'client_email' => 'clientemail@clientemail.com', - 'private_key' => "-----BEGIN RSA PRIVATE KEY-----\nMIICWgIBAAKBgGhw1WMos5gp2YjV7+fNwXN1tI4/DFXKzwY6TDWsPxkbyfjHgunX\n/sijlnJt3Qs1gBxiwEEjzFFlp39O3/gEbIoYWHR/4sZdqNRFzbhJcTpnUvRlZDBL\nE5h8f5uu4aL4D32WyiELF/vpr533lZCBwWsnN3zIYJxThgRF9i/R7F8tAgMBAAEC\ngYAgUyv4cNSFOA64J18FY82IKtojXKg4tXi1+L01r4YoA03TzgxazBtzhg4+hHpx\nybFJF9dhUe8fElNxN7xiSxw8i5MnfPl+piwbfoENhgrzU0/N14AV/4Pq+WAJQe2M\nxPcI1DPYMEwGjX2PmxqnkC47MyR9agX21YZVc9rpRCgPgQJBALodH492I0ydvEUs\ngT+3DkNqoWx3O3vut7a0+6k+RkM1Yu+hGI8RQDCGwcGhQlOpqJkYGsVegZbxT+AF\nvvIFrIUCQQCPqJbRalHK/QnVj4uovj6JvjTkqFSugfztB4Zm/BPT2eEpjLt+851d\nIJ4brK/HVkQT2zk9eb0YzIBfeQi9WpyJAkB9+BRSf72or+KsV1EsFPScgOG9jn4+\nhfbmvVzQ0ouwFcRfOQRsYVq2/Z7LNiC0i9LHvF7yU+MWjUJo+LqjCWAZAkBHearo\nMIzXgQRGlC/5WgZFhDRO3A2d8aDE0eymCp9W1V24zYNwC4dtEVB5Fncyp5Ihiv40\nvwA9eWoZll+pzo55AkBMMdk95skWeaRv8T0G1duv5VQ7q4us2S2TKbEbC8j83BTP\nNefc3KEugylyAjx24ydxARZXznPi1SFeYVx1KCMZ\n-----END RSA PRIVATE KEY-----\n", - 'type' => 'service_account', - ] - ]; + // 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); - // Service Account to Service Account Impersonation JSON Credentials - private const EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON = [ - 'type' => 'impersonated_service_account', - 'service_account_impersonation_url' => self::IMPERSONATION_URL, - 'source_credentials' => [ - 'type' => 'external_account', - 'audience' => 'some_audience', - 'subject_token_type' => 'access_token', - 'token_url' => 'https://sts.googleapis.com/v1/token', - 'credential_source' => [ - 'url' => 'https://some.url/token' - ] - ] - ]; + // 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); + } } diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php index f06b4f28eb..c71e8746fc 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. From 7fafae99a41984cbfb92508174263cf7bf3049b9 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:21:37 -0800 Subject: [PATCH 2/2] chore(main): release 1.46.0 (#611) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ca9a6cae..aa2817338c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.46.0](https://github.com/googleapis/google-auth-library-php/compare/v1.45.4...v1.46.0) (2025-02-12) + + +### Features + +* Add support for Impersonating ID Tokens ([#580](https://github.com/googleapis/google-auth-library-php/issues/580)) ([66db27c](https://github.com/googleapis/google-auth-library-php/commit/66db27c671c229ff561ecab51e0b6379c6109b93)) + ## [1.45.4](https://github.com/googleapis/google-auth-library-php/compare/v1.45.3...v1.45.4) (2025-02-05) diff --git a/VERSION b/VERSION index 4d3b50f246..0a3db35b24 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.45.4 +1.46.0