Skip to content

Commit

Permalink
feat: add support for Impersonated Service Account Credentials (#580)
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer committed Nov 5, 2024
1 parent 6b00b66 commit 4d34f14
Show file tree
Hide file tree
Showing 13 changed files with 567 additions and 97 deletions.
14 changes: 7 additions & 7 deletions src/ApplicationDefaultCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -299,13 +300,12 @@ public static function getIdTokenCredentials(
throw new \InvalidArgumentException('json key is missing the type field');
}

if ($jsonKey['type'] == 'authorized_user') {
$creds = new UserRefreshCredentials(null, $jsonKey, $targetAudience);
} elseif ($jsonKey['type'] == 'service_account') {
$creds = new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience);
} else {
throw new InvalidArgumentException('invalid value in the type field');
}
$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')
};
} elseif (self::onGce($httpHandler, $cacheConfig, $cache)) {
$creds = new GCECredentials(null, null, $targetAudience);
$creds->setIsOnGce(true); // save the credentials a trip to the metadata server
Expand Down
6 changes: 4 additions & 2 deletions src/Credentials/ExternalAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHan

/**
* @param callable|null $httpHandler
* @param array<mixed> $headers [optional] Metrics headers to be inserted
* into the token endpoint request present.
*
* @return array<mixed> {
* A set of auth related metadata, containing the following
Expand All @@ -263,9 +265,9 @@ private function getImpersonatedAccessToken(string $stsToken, ?callable $httpHan
* @type string $token_type (identity pool only)
* }
*/
public function fetchAuthToken(?callable $httpHandler = null)
public function fetchAuthToken(?callable $httpHandler = null, array $headers = [])
{
$stsToken = $this->auth->fetchAuthToken($httpHandler);
$stsToken = $this->auth->fetchAuthToken($httpHandler, $headers);

if (isset($this->serviceAccountImpersonationUrl)) {
return $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler);
Expand Down
6 changes: 4 additions & 2 deletions src/Credentials/GCECredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ private static function detectResidencyWindows(string $registryProductKey): bool
* If $httpHandler is not specified a the default HttpHandler is used.
*
* @param callable|null $httpHandler callback which delivers psr7 request
* @param array<mixed> $headers [optional] Headers to be inserted
* into the token endpoint request present.
*
* @return array<mixed> {
* A set of auth related metadata, based on the token type.
Expand All @@ -453,7 +455,7 @@ private static function detectResidencyWindows(string $registryProductKey): bool
* }
* @throws \Exception
*/
public function fetchAuthToken(?callable $httpHandler = null)
public function fetchAuthToken(?callable $httpHandler = null, array $headers = [])
{
$httpHandler = $httpHandler
?: HttpHandlerFactory::build(HttpClientCache::getHttpClient());
Expand All @@ -469,7 +471,7 @@ public function fetchAuthToken(?callable $httpHandler = null)
$response = $this->getFromMetadata(
$httpHandler,
$this->tokenUri,
$this->applyTokenEndpointMetrics([], $this->targetAudience ? 'it' : 'at')
$this->applyTokenEndpointMetrics($headers, $this->targetAudience ? 'it' : 'at')
);

if ($this->targetAudience) {
Expand Down
137 changes: 117 additions & 20 deletions src/Credentials/ImpersonatedServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,65 +18,114 @@

namespace Google\Auth\Credentials;

use Google\Auth\CacheTrait;
use Google\Auth\CredentialsLoader;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use Google\Auth\IamSignerTrait;
use Google\Auth\SignBlobInterface;
use GuzzleHttp\Psr7\Request;
use InvalidArgumentException;
use LogicException;

class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface
{
use CacheTrait;
use IamSignerTrait;

private const CRED_TYPE = 'imp';
private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam';

/**
* @var string
*/
protected $impersonatedServiceAccountName;

protected FetchAuthTokenInterface $sourceCredentials;

private string $serviceAccountImpersonationUrl;

/**
* @var string[]
*/
private array $delegates;

/**
* @var UserRefreshCredentials
* @var string|string[]
*/
protected $sourceCredentials;
private string|array $targetScope;

private int $lifetime;

/**
* Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that
* has be created with the --impersonated-service-account flag.
* has be created with the --impersonate-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|array<mixed> $jsonKey JSON credential file path or JSON credentials
* as an associative array.
* @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<mixed> $jsonKey JSON credential file path or JSON array credentials {
* JSON credentials as an associative array.
*
* @type string $service_account_impersonation_url The URL to the service account
* @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate
* @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)) {
throw new \InvalidArgumentException('file does not exist');
throw new InvalidArgumentException('file does not exist');
}
$json = file_get_contents($jsonKey);
if (!$jsonKey = json_decode((string) $json, true)) {
throw new \LogicException('invalid json for auth config');
throw new LogicException('invalid json for auth config');
}
}
if (!array_key_exists('service_account_impersonation_url', $jsonKey)) {
throw new \LogicException(
throw new LogicException(
'json key is missing the service_account_impersonation_url field'
);
}
if (!array_key_exists('source_credentials', $jsonKey)) {
throw new \LogicException('json key is missing the source_credentials field');
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']);
}

$this->targetScope = $scope ?? [];
$this->lifetime = $jsonKey['lifetime'] ?? 3600;
$this->delegates = $jsonKey['delegates'] ?? [];

$this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'];
$this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl(
$jsonKey['service_account_impersonation_url']
$this->serviceAccountImpersonationUrl
);

$this->sourceCredentials = new UserRefreshCredentials(
$scope,
$jsonKey['source_credentials']
);
$this->sourceCredentials = $jsonKey['source_credentials'];
}

/**
Expand Down Expand Up @@ -123,11 +172,52 @@ public function getClientName(?callable $unusedHttpHandler = null)
*/
public function fetchAuthToken(?callable $httpHandler = null)
{
// We don't support id token endpoint requests as of now for Impersonated Cred
return $this->sourceCredentials->fetchAuthToken(
$httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient());

// The FetchAuthTokenInterface technically does not have a "headers" argument, but all of
// the implementations do. Additionally, passing in more parameters than the function has
// defined is allowed in PHP. So we'll just ignore the phpstan error here.
// @phpstan-ignore-next-line
$authToken = $this->sourceCredentials->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics([], 'at')
);

$headers = $this->applyTokenEndpointMetrics([
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store',
'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']),
], $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',
$this->serviceAccountImpersonationUrl,
$headers,
(string) json_encode($body)
);

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

return match ($this->isIdTokenRequest()) {
true => ['id_token' => $body['token']],
false => [
'access_token' => $body['accessToken'],
'expires_at' => strtotime($body['expireTime']),
]
};
}

/**
Expand All @@ -138,7 +228,9 @@ public function fetchAuthToken(?callable $httpHandler = null)
*/
public function getCacheKey()
{
return $this->sourceCredentials->getCacheKey();
return $this->getFullCacheKey(
$this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey()
);
}

/**
Expand All @@ -153,4 +245,9 @@ protected function getCredType(): string
{
return self::CRED_TYPE;
}

private function isIdTokenRequest(): bool
{
return !is_null($this->targetAudience);
}
}
24 changes: 14 additions & 10 deletions src/Credentials/ServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ class ServiceAccountCredentials extends CredentialsLoader implements
*/
private string $universeDomain;

/**
* Whether this is an ID token request or an access token request. Used when
* building the metric header.
*/
private bool $isIdTokenRequest = false;

/**
* Create a new ServiceAccountCredentials.
*
Expand Down Expand Up @@ -161,6 +167,7 @@ public function __construct(
$additionalClaims = [];
if ($targetAudience) {
$additionalClaims = ['target_audience' => $targetAudience];
$this->isIdTokenRequest = true;
}
$this->auth = new OAuth2([
'audience' => self::TOKEN_CREDENTIAL_URI,
Expand Down Expand Up @@ -194,6 +201,8 @@ public function useJwtAccessWithScope()

/**
* @param callable|null $httpHandler
* @param array<mixed> $headers [optional] Headers to be inserted
* into the token endpoint request present.
*
* @return array<mixed> {
* A set of auth related metadata, containing the following
Expand All @@ -203,7 +212,7 @@ public function useJwtAccessWithScope()
* @type string $token_type
* }
*/
public function fetchAuthToken(?callable $httpHandler = null)
public function fetchAuthToken(?callable $httpHandler = null, array $headers = [])
{
if ($this->useSelfSignedJwt()) {
$jwtCreds = $this->createJwtAccessCredentials();
Expand All @@ -218,7 +227,7 @@ public function fetchAuthToken(?callable $httpHandler = null)
return $accessToken;
}

if ($this->isIdTokenRequest() && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) {
if ($this->isIdTokenRequest && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) {
$now = time();
$jwt = Jwt::encode(
[
Expand All @@ -237,13 +246,13 @@ public function fetchAuthToken(?callable $httpHandler = null)
$this->auth->getIssuer(),
$this->auth->getAdditionalClaims()['target_audience'],
$jwt,
$this->applyTokenEndpointMetrics([], 'it')
$this->applyTokenEndpointMetrics($headers, 'it')
);
return ['id_token' => $idToken];
}
return $this->auth->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics([], $this->isIdTokenRequest() ? 'it' : 'at')
$this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at')
);
}

Expand Down Expand Up @@ -429,7 +438,7 @@ private function useSelfSignedJwt()
}

// Do not use self-signed JWT for ID tokens
if ($this->isIdTokenRequest()) {
if ($this->isIdTokenRequest) {
return false;
}

Expand All @@ -445,9 +454,4 @@ private function useSelfSignedJwt()

return is_null($this->auth->getScope());
}

private function isIdTokenRequest(): bool
{
return !empty($this->auth->getAdditionalClaims()['target_audience']);
}
}
6 changes: 3 additions & 3 deletions src/Credentials/UserRefreshCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public function __construct(

/**
* @param callable|null $httpHandler
* @param array<mixed> $metricsHeader [optional] Metrics headers to be inserted
* @param array<mixed> $headers [optional] Metrics headers to be inserted
* into the token endpoint request present.
* This could be passed from ImersonatedServiceAccountCredentials as it uses
* UserRefreshCredentials as source credentials.
Expand All @@ -141,11 +141,11 @@ public function __construct(
* @type string $id_token
* }
*/
public function fetchAuthToken(?callable $httpHandler = null, array $metricsHeader = [])
public function fetchAuthToken(?callable $httpHandler = null, array $headers = [])
{
return $this->auth->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics($metricsHeader, $this->isIdTokenRequest ? 'it' : 'at')
$this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at')
);
}

Expand Down
1 change: 1 addition & 0 deletions src/CredentialsLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public static function fromEnv()
throw new \DomainException(self::unableToReadEnv($cause));
}
$jsonKey = file_get_contents($path);

return json_decode((string) $jsonKey, true);
}

Expand Down
Loading

0 comments on commit 4d34f14

Please sign in to comment.