Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Impersonated Service Account Credentials #580

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get an error (http 400) here about the json payload in the outgoing request "Invalid JSON payload received. Unknown name "audience": Cannot find ...". When I update the url by replacing generateAccessToken to generateIdToken everything works as expected!

This is what I changed:

        if ($this->isIdTokenRequest()) {
            $url = str_replace('generateAccessToken', 'generateIdToken', $this->serviceAccountImpersonationUrl);
            $body = [
                'audience' => $this->targetAudience,
                'includeEmail' => true,
            ];
        } else {
            $body = [
                'scope' => $this->targetScope,
                'delegates' => $this->delegates,
                'lifetime' => sprintf('%ss', $this->lifetime),
            ];
            $url = $this->serviceAccountImpersonationUrl;
        }

        $request = new Request(
            'POST',
            $url,
            $headers,
            (string) json_encode($body)
        );

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gjvanahee that value is defined in your credentials in the service_account_impersonation_url JSON value. I do not want to do a string find-and-replace on values in the credentials, so I'm not sure what the appropriate approach is here.

Python does seem to do some sort of templating here, so maybe this requires further consideration:

https://github.com/googleapis/google-auth-library-python/blob/484c8db151690a4ae7b6b0ae38db0a8ede88df69/google/auth/iam.py#L41-L54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the str_replace. I just wanted to make the change more explicit. I don't like to have to change a generated key file. In my PR I used "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{$impersonatedServiceAccount}:generateIdToken", but it looks a lot nicer to have all the options together in constants like in the python example

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing we could do (and maybe this is what Python does, I need to llook into it still) is not support service_account_impersonation_url value in jsonKey for ID tokens (as this is tied directly to the JSON credentials file, which is not supported for ID tokens by gcloud), and instead use the IAM endpoint template.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could Google\Auth\Iam class be changed to add methods for requesting an id token (and perhaps access_token)? All the actions on that endpoint would then be together in that class. Something like

    private const ID_TOKEN_PATH = '%s:generateIdToken';
    public function generateIdToken(string $email, string $targetAudience, string $accessToken): string
    {
        $httpHandler = $this->httpHandler;
        $name = sprintf(self::SERVICE_ACCOUNT_NAME, $email);
        $apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE);
        $uri = $apiRoot . '/' . sprintf(self::ID_TOKEN_PATH, $name);

        $body = [
            'audience' => $targetAudience,
            'includeEmail' => true,
        ];

        $headers = [
            'Authorization' => 'Bearer ' . $accessToken,
            'Content-Type' => 'application/json',
            'Cache-Control' => 'no-store',
        ];

        $request = new Psr7\Request(
            'POST',
            $uri,
            $headers,
            Utils::streamFor(json_encode($body))
        );

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

        return $body['token'];
    }

and some refactoring to avoid duplication of course.
It would skip the call to applyTokenEndpointMetrics though or that has to be used in the Iam class too...

Copy link
Contributor Author

@bshaffer bshaffer Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You seem to be describing the work I did in #581 :)

The only downside here is that we are not respecting the service_account_impersonation_url in the credentials, which might be fine, but we need to make sure this is okay to do. Well, we are respecting it in the sense that we are stripping out the clientEmail from it, which I'm not sure is better or worse.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hehehe, it seems I'm always one step behind... I also found it strange that the service account being impersonated is not explicitly mentioned in the key file. The endpoints for the actions are documented and discoverable, but that is your call ;)

$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