Skip to content

Commit 66db27c

Browse files
authored
feat: add support for Impersonating ID Tokens (#580)
1 parent c70b987 commit 66db27c

File tree

5 files changed

+373
-64
lines changed

5 files changed

+373
-64
lines changed

src/ApplicationDefaultCredentials.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use DomainException;
2121
use Google\Auth\Credentials\AppIdentityCredentials;
2222
use Google\Auth\Credentials\GCECredentials;
23+
use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials;
2324
use Google\Auth\Credentials\ServiceAccountCredentials;
2425
use Google\Auth\Credentials\UserRefreshCredentials;
2526
use Google\Auth\HttpHandler\HttpClientCache;
@@ -307,6 +308,7 @@ public static function getIdTokenCredentials(
307308

308309
$creds = match ($jsonKey['type']) {
309310
'authorized_user' => new UserRefreshCredentials(null, $jsonKey, $targetAudience),
311+
'impersonated_service_account' => new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience),
310312
'service_account' => new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience),
311313
default => throw new InvalidArgumentException('invalid value in the type field')
312314
};

src/Credentials/ImpersonatedServiceAccountCredentials.php

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Google\Auth\CacheTrait;
2222
use Google\Auth\CredentialsLoader;
2323
use Google\Auth\FetchAuthTokenInterface;
24+
use Google\Auth\GetUniverseDomainInterface;
2425
use Google\Auth\HttpHandler\HttpClientCache;
2526
use Google\Auth\HttpHandler\HttpHandlerFactory;
2627
use Google\Auth\IamSignerTrait;
@@ -29,12 +30,17 @@
2930
use InvalidArgumentException;
3031
use LogicException;
3132

32-
class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface
33+
class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
34+
SignBlobInterface,
35+
GetUniverseDomainInterface
3336
{
3437
use CacheTrait;
3538
use IamSignerTrait;
3639

3740
private const CRED_TYPE = 'imp';
41+
private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam';
42+
private const ID_TOKEN_IMPERSONATION_URL =
43+
'https://iamcredentials.UNIVERSE_DOMAIN/v1/projects/-/serviceAccounts/%s:generateIdToken';
3844

3945
/**
4046
* @var string
@@ -71,10 +77,12 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
7177
* @type int $lifetime The lifetime of the impersonated credentials
7278
* @type string[] $delegates The delegates to impersonate
7379
* }
80+
* @param string|null $targetAudience The audience to request an ID token.
7481
*/
7582
public function __construct(
76-
$scope,
77-
$jsonKey
83+
string|array|null $scope,
84+
string|array $jsonKey,
85+
private ?string $targetAudience = null
7886
) {
7987
if (is_string($jsonKey)) {
8088
if (!file_exists($jsonKey)) {
@@ -93,10 +101,23 @@ public function __construct(
93101
if (!array_key_exists('source_credentials', $jsonKey)) {
94102
throw new LogicException('json key is missing the source_credentials field');
95103
}
104+
if ($scope && $targetAudience) {
105+
throw new InvalidArgumentException(
106+
'Scope and targetAudience cannot both be supplied'
107+
);
108+
}
96109
if (is_array($jsonKey['source_credentials'])) {
97110
if (!array_key_exists('type', $jsonKey['source_credentials'])) {
98111
throw new InvalidArgumentException('json key source credentials are missing the type field');
99112
}
113+
if (
114+
$targetAudience !== null
115+
&& $jsonKey['source_credentials']['type'] === 'service_account'
116+
) {
117+
// Service account tokens MUST request a scope, and as this token is only used to impersonate
118+
// an ID token, the narrowest scope we can request is `iam`.
119+
$scope = self::IAM_SCOPE;
120+
}
100121
$jsonKey['source_credentials'] = CredentialsLoader::makeCredentials($scope, $jsonKey['source_credentials']);
101122
}
102123

@@ -171,28 +192,52 @@ public function fetchAuthToken(?callable $httpHandler = null)
171192
'Content-Type' => 'application/json',
172193
'Cache-Control' => 'no-store',
173194
'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']),
174-
], 'at');
195+
], $this->isIdTokenRequest() ? 'it' : 'at');
196+
197+
$body = match ($this->isIdTokenRequest()) {
198+
true => [
199+
'audience' => $this->targetAudience,
200+
'includeEmail' => true,
201+
],
202+
false => [
203+
'scope' => $this->targetScope,
204+
'delegates' => $this->delegates,
205+
'lifetime' => sprintf('%ss', $this->lifetime),
206+
]
207+
};
175208

176-
$body = [
177-
'scope' => $this->targetScope,
178-
'delegates' => $this->delegates,
179-
'lifetime' => sprintf('%ss', $this->lifetime),
180-
];
209+
$url = $this->serviceAccountImpersonationUrl;
210+
if ($this->isIdTokenRequest()) {
211+
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
212+
if (!preg_match($regex, $url, $matches)) {
213+
throw new InvalidArgumentException(
214+
'Invalid service account impersonation URL - unable to parse service account email'
215+
);
216+
}
217+
$url = str_replace(
218+
'UNIVERSE_DOMAIN',
219+
$this->getUniverseDomain(),
220+
sprintf(self::ID_TOKEN_IMPERSONATION_URL, $matches['email'])
221+
);
222+
}
181223

182224
$request = new Request(
183225
'POST',
184-
$this->serviceAccountImpersonationUrl,
226+
$url,
185227
$headers,
186228
(string) json_encode($body)
187229
);
188230

189231
$response = $httpHandler($request);
190232
$body = json_decode((string) $response->getBody(), true);
191233

192-
return [
193-
'access_token' => $body['accessToken'],
194-
'expires_at' => strtotime($body['expireTime']),
195-
];
234+
return match ($this->isIdTokenRequest()) {
235+
true => ['id_token' => $body['token']],
236+
false => [
237+
'access_token' => $body['accessToken'],
238+
'expires_at' => strtotime($body['expireTime']),
239+
]
240+
};
196241
}
197242

198243
/**
@@ -220,4 +265,16 @@ protected function getCredType(): string
220265
{
221266
return self::CRED_TYPE;
222267
}
268+
269+
private function isIdTokenRequest(): bool
270+
{
271+
return !is_null($this->targetAudience);
272+
}
273+
274+
public function getUniverseDomain(): string
275+
{
276+
return $this->sourceCredentials instanceof GetUniverseDomainInterface
277+
? $this->sourceCredentials->getUniverseDomain()
278+
: self::DEFAULT_UNIVERSE_DOMAIN;
279+
}
223280
}

tests/ApplicationDefaultCredentialsTest.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
use GuzzleHttp\Psr7;
3333
use GuzzleHttp\Psr7\Response;
3434
use GuzzleHttp\Psr7\Utils;
35-
use PHPUnit\Framework\Error\Notice;
3635
use PHPUnit\Framework\TestCase;
3736
use Prophecy\PhpUnit\ProphecyTrait;
3837
use Psr\Cache\CacheItemPoolInterface;
@@ -499,6 +498,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound()
499498
);
500499
}
501500

501+
public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials()
502+
{
503+
putenv('HOME=' . __DIR__ . '/fixtures5');
504+
$creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com');
505+
$this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds);
506+
}
507+
502508
public function testGetIdTokenCredentialsWithCacheOptions()
503509
{
504510
$keyFile = __DIR__ . '/fixtures' . '/private.json';
@@ -803,10 +809,16 @@ public function testGetDefaultLoggerReturnsNullIfNotEnvVar()
803809
public function testGetDefaultLoggerRaiseAWarningIfMisconfiguredAndReturnsNull()
804810
{
805811
putenv($this::SDK_DEBUG_ENV_VAR . '=invalid');
806-
$this->expectException(Notice::class);
807-
$logger = ApplicationDefaultCredentials::getDefaultLogger();
808812

809-
$this->assertNull($logger);
813+
$this->expectExceptionMessage(
814+
'The GOOGLE_SDK_PHP_LOGGING is set, but it is set to another value than false or true'
815+
);
816+
817+
set_error_handler(static function (int $errno, string $errstr): never {
818+
throw new \Exception($errstr, $errno);
819+
}, E_USER_NOTICE);
820+
821+
ApplicationDefaultCredentials::getDefaultLogger();
810822
}
811823

812824
public function provideExternalAccountCredentials()

0 commit comments

Comments
 (0)