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 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
22d07e2
WIP
bshaffer Oct 1, 2024
203722d
open up jsonKey to any credential class
bshaffer Oct 1, 2024
ba37442
fix typos
bshaffer Oct 1, 2024
f5a1057
support ID token impersonation
bshaffer Oct 1, 2024
3b38b22
fix FetchAuthTokenInterface typehint
bshaffer Oct 1, 2024
15b6c33
add support for impersonating id tokens
bshaffer Oct 1, 2024
acd55a3
fix phpstan and Obervability test
bshaffer Oct 3, 2024
522f605
fix phpstan errors, add headers parameter for other credential types
bshaffer Oct 3, 2024
c50541e
fix cs and tests
bshaffer Oct 3, 2024
fa156fa
fix phpstan
bshaffer Oct 4, 2024
f9a2835
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Oct 4, 2024
20f2c56
add tests
bshaffer Oct 4, 2024
bdf24f8
Merge branch 'add-impersonated-service-account-credentials' of github…
bshaffer Oct 4, 2024
244724f
add impersonate id token metric test
bshaffer Oct 5, 2024
66b099b
fix cs and phpstan
bshaffer Oct 5, 2024
58b0ed6
add tests for arbitrary credentials
bshaffer Oct 5, 2024
19ae608
add test for Impersonating ExternalAccountCreds
bshaffer Oct 5, 2024
a70d236
ensure complete test coverage, fix cs
bshaffer Oct 5, 2024
c63443c
change scope to auth/iam, use constant
bshaffer Oct 7, 2024
b09e8c3
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Nov 5, 2024
df63099
fix test
bshaffer Nov 5, 2024
54b2284
fix cs
bshaffer Nov 5, 2024
dca23f0
fix case
bshaffer Nov 5, 2024
837eba5
Merge branch 'main' into add-impersonated-service-account-credentials
bshaffer Nov 13, 2024
76f48e6
Update src/FetchAuthTokenInterface.php
bshaffer Nov 13, 2024
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
5 changes: 5 additions & 0 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\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
Expand Down Expand Up @@ -302,6 +303,10 @@ public static function getIdTokenCredentials(
throw new InvalidArgumentException('ID tokens are not supported for end user credentials');
}

if ($jsonKey['type'] == 'impersonated_service_account') {
return new ImpersonatedServiceAccountCredentials(null, $jsonKey, $targetAudience);
}

if ($jsonKey['type'] != 'service_account') {
throw new InvalidArgumentException('invalid value in the type field');
}
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 $httpHand

/**
* @param callable $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 $httpHand
* @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
7 changes: 5 additions & 2 deletions src/Credentials/GCECredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,9 @@ private static function detectResidencyWindows(string $registryProductKey): bool
* If $httpHandler is not specified a the default HttpHandler is used.
*
* @param callable $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 +456,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 +472,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
136 changes: 116 additions & 20 deletions src/Credentials/ImpersonatedServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@

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';
Expand All @@ -33,50 +41,90 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
*/
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 `cloud-platform`.
$scope = 'https://www.googleapis.com/auth/cloud-platform';
}
$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 +171,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 +227,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 +244,9 @@ protected function getCredType(): string
{
return self::CRED_TYPE;
}

private function isIdTokenRequest(): bool
{
return !is_null($this->targetAudience);
}
}
9 changes: 7 additions & 2 deletions src/Credentials/ServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ public function useJwtAccessWithScope()

/**
* @param callable $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 @@ -199,7 +201,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 @@ -215,7 +217,10 @@ public function fetchAuthToken(callable $httpHandler = null)
}
$authRequestType = empty($this->auth->getAdditionalClaims()['target_audience'])
? 'at' : 'it';
return $this->auth->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], $authRequestType));
return $this->auth->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics($headers, $authRequestType)
);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/Credentials/UserRefreshCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public function __construct(

/**
* @param callable $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 @@ -120,12 +120,12 @@ public function __construct(
* @type string $id_token
* }
*/
public function fetchAuthToken(callable $httpHandler = null, array $metricsHeader = [])
public function fetchAuthToken(callable $httpHandler = null, array $headers = [])
{
// We don't support id token endpoint requests as of now for User Cred
return $this->auth->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics($metricsHeader, 'at')
$this->applyTokenEndpointMetrics($headers, '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
1 change: 1 addition & 0 deletions src/FetchAuthTokenInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface FetchAuthTokenInterface
* Fetches the auth tokens based on the current state.
*
* @param callable $httpHandler callback which delivers psr7 request
*
bshaffer marked this conversation as resolved.
Show resolved Hide resolved
* @return array<mixed> a hash of auth tokens
*/
public function fetchAuthToken(callable $httpHandler = null);
Expand Down
4 changes: 2 additions & 2 deletions src/OAuth2.php
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ public function toJwt(array $config = [])
* the token endpoint request.
* @return RequestInterface the authorization Url.
*/
public function generateCredentialsRequest(callable $httpHandler = null, $headers = [])
public function generateCredentialsRequest(callable $httpHandler = null, array $headers = [])
{
$uri = $this->getTokenCredentialUri();
if (is_null($uri)) {
Expand Down Expand Up @@ -666,7 +666,7 @@ public function generateCredentialsRequest(callable $httpHandler = null, $header
* endpoint request.
* @return array<mixed> the response
*/
public function fetchAuthToken(callable $httpHandler = null, $headers = [])
public function fetchAuthToken(callable $httpHandler = null, array $headers = [])
{
if (is_null($httpHandler)) {
$httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());
Expand Down
8 changes: 8 additions & 0 deletions tests/ApplicationDefaultCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Google\Auth\ApplicationDefaultCredentials;
use Google\Auth\Credentials\ExternalAccountCredentials;
use Google\Auth\Credentials\GCECredentials;
use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Google\Auth\CredentialsLoader;
use Google\Auth\CredentialSource;
Expand Down Expand Up @@ -509,6 +510,13 @@ public function testGetIdTokenCredentialsFailsIfNotOnGceAndNoDefaultFileFound()
$this->assertNotNull($creds);
}

public function testGetIdTokenCredentialsWithImpersonatedServiceAccountCredentials()
{
putenv('HOME=' . __DIR__ . '/fixtures5');
$creds = ApplicationDefaultCredentials::getIdTokenCredentials('[email protected]');
$this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds);
}

public function testGetIdTokenCredentialsWithCacheOptions()
{
$keyFile = __DIR__ . '/fixtures' . '/private.json';
Expand Down
Loading