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: Get id token for impersonated service account #579

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 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,11 +303,13 @@ public static function getIdTokenCredentials(
throw new InvalidArgumentException('ID tokens are not supported for end user credentials');
}

if ($jsonKey['type'] != 'service_account') {
if (! in_array($jsonKey['type'], ['service_account', 'impersonated_service_account'])) {
throw new InvalidArgumentException('invalid value in the type field');
}

$creds = new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience);
$creds = $jsonKey['type'] === 'service_account'
? new ServiceAccountCredentials(null, $jsonKey, null, $targetAudience)
: new ImpersonatedServiceAccountCredentials(null, $jsonKey, null, $targetAudience);
} 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
90 changes: 78 additions & 12 deletions src/Credentials/ImpersonatedServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@

namespace Google\Auth\Credentials;

use Exception;
use Google\Auth\CredentialsLoader;
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use Google\Auth\IamSignerTrait;
use Google\Auth\OAuth2;
use Google\Auth\SignBlobInterface;
use GuzzleHttp\Psr7\Request;
use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;

class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface
{
Expand All @@ -31,25 +38,37 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements
/**
* @var string
*/
protected $impersonatedServiceAccountName;
protected string $impersonatedServiceAccountName;

/**
* @var UserRefreshCredentials
*/
protected $sourceCredentials;
protected UserRefreshCredentials $sourceCredentials;

/**
* @var array{target_audience?: string} Additional claims for the id token
*/
protected array $additionalClaims;

/**
* Instantiate an instance of ImpersonatedServiceAccountCredentials from a credentials file that
* has be created with the --impersonated-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|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 credentials
* as an associative array.
* as an associative array.
* @param string|null $sub an email address account to impersonate, in situations when
* the service account has been delegated domain wide access.
* @param string|null $targetAudience The audience for the ID token.
*/
public function __construct(
$scope,
$jsonKey
string|array|null $scope,
string|array $jsonKey,
// sub is currently not implemented but specified to keep the order of arguments
// the same as ServiceAccountCredentials
string $sub = null,
string $targetAudience = null
) {
if (is_string($jsonKey)) {
if (!file_exists($jsonKey)) {
Expand All @@ -69,10 +88,21 @@ public function __construct(
throw new \LogicException('json key is missing the source_credentials field');
}

if ($scope && $targetAudience) {
throw new InvalidArgumentException(
'Scope and targetAudience cannot both be supplied'
);
}

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

$this->additionalClaims = [];
if ($targetAudience) {
$this->additionalClaims = ['target_audience' => $targetAudience];
}

$this->sourceCredentials = new UserRefreshCredentials(
$scope,
$jsonKey['source_credentials']
Expand Down Expand Up @@ -109,25 +139,61 @@ public function getClientName(callable $unusedHttpHandler = null)
}

/**
* @param callable $httpHandler
* Get an auth token.
*
* @param callable $httpHandler
* @return array<mixed> {
* A set of auth related metadata, containing the following
*
* @type string $access_token
* @type int $expires_in
* @type string $scope
* @type string $token_type
* @type string $id_token
* @type ?string $id_token
* }
* @throws Exception
*/
public function fetchAuthToken(callable $httpHandler = null)
public function fetchAuthToken(callable $httpHandler = null): array
{
// We don't support id token endpoint requests as of now for Impersonated Cred
return $this->sourceCredentials->fetchAuthToken(
$tokens = $this->sourceCredentials->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics([], 'at')
);

// the authRequestType='it' does not work
// fetch an id token using the access token from iam credentials
if (array_key_exists('target_audience', $this->additionalClaims)) {
if (is_null($httpHandler)) {
$httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());
}

$impersonatedServiceAccount = $this->getClientName();
$request = new Request(
'POST',
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{$impersonatedServiceAccount}:generateIdToken",
[
'Authorization' => "Bearer {$tokens['access_token']}",
'Cache-Control' => 'no-store',
'Content-Type' => 'application/json',
],
json_encode([
'audience' => $this->additionalClaims['target_audience'],
'includeEmail' => true,
])
);
$body = (string) $httpHandler($request)->getBody();

// Assume it's JSON; if it's not throw an exception
if (null === $res = json_decode($body, true)) {
throw new Exception('Invalid JSON response');
}
// we cannot append the id_token to the list of tokens already fetched
// as the AuthTokenMiddleware will first try to set the access_token if
// it can find it.
$tokens = ['id_token' => $res['token']];
}

return $tokens;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Middleware/AuthTokenMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

namespace Google\Auth\Middleware;

use Exception;
use Google\Auth\FetchAuthTokenCache;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\GetQuotaProjectInterface;
Expand Down Expand Up @@ -123,6 +124,7 @@ public function __invoke(callable $handler)
*
* @param RequestInterface $request
* @return RequestInterface
* @throws Exception
*/
private function addAuthHeaders(RequestInterface $request)
{
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 @@ -155,6 +156,13 @@ public function testGceCredentials()
$this->assertStringContainsString('a+user+scope', $tokenUri);
}

public function testGetIdTokenCredentialsCanFindImpersonatedServiceAccountCredentials()
{
putenv('GOOGLE_APPLICATION_CREDENTIALS=' . __DIR__ . '/fixtures3/impersonated_service_account_credentials.json');
$creds = ApplicationDefaultCredentials::getIdTokenCredentials('123@456.com');
$this->assertInstanceOf(ImpersonatedServiceAccountCredentials::class, $creds);
}

public function testImpersonatedServiceAccountCredentials()
{
putenv('HOME=' . __DIR__ . '/fixtures5');
Expand Down
93 changes: 83 additions & 10 deletions tests/Credentials/ImpersonatedServiceAccountCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,25 @@
namespace Google\Auth\Tests\Credentials;

use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials;
use Google\Auth\Credentials\UserRefreshCredentials;
use Google\Auth\Middleware\AuthTokenMiddleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use LogicException;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;

class ImpersonatedServiceAccountCredentialsTest extends TestCase
{
use ProphecyTrait;

// Creates a standard JSON auth object for testing.
private function createISACTestJson()
{
return [
'type' => 'impersonated_service_account',
'service_account_impersonation_url' => 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken',
'source_credentials' => [
'client_id' => 'client123',
'client_secret' => 'clientSecret123',
'refresh_token' => 'refreshToken123',
'type' => 'authorized_user',
]
];
return json_decode(file_get_contents(__DIR__ . '/../fixtures3/impersonated_service_account_credentials.json'), true);
}

public function testGetServiceAccountNameEmail()
Expand Down Expand Up @@ -69,4 +70,76 @@ public function testErrorCredentials()
$this->expectException(LogicException::class);
new ImpersonatedServiceAccountCredentials($scope, $testJson['source_credentials']);
}

public function testGetIdToken()
{
$testJson = $this->createISACTestJson();
$targetAudience = '123@456.com';
$creds = new ImpersonatedServiceAccountCredentials(null, $testJson, null, $targetAudience);

$requestCount = 0;
// getting an id token will take two requests
$httpHandler = function (RequestInterface $request) use (&$requestCount, $creds) {
$impersonatedServiceAccount = $creds->getClientName();

$responseBody = '';
switch (++$requestCount) {
case 1: // the call to swap the refresh token for an access token
$this->assertEquals(UserRefreshCredentials::TOKEN_CREDENTIAL_URI, (string) $request->getUri());
$responseBody = '{"access_token":"this is an access token"}';
break;

case 2: // the call to swap the access token for an id token
$this->assertEquals("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{$impersonatedServiceAccount}:generateIdToken", (string) $request->getUri());
$authHeader = $request->getHeader('authorization');
$this->assertCount(1, $authHeader);
$this->assertEquals('Bearer this is an access token', $authHeader[0]);
$responseBody = '{"token": "this is the id token"}';
break;
}

$body = $this->prophesize(StreamInterface::class);
$body->__toString()->willReturn($responseBody);

$response = $this->prophesize(ResponseInterface::class);
$response->getBody()->willReturn($body->reveal());
$response->hasHeader('Content-Type')->willReturn(true);
$response->getHeaderLine('Content-Type')->willReturn('application/json');

if ($requestCount === 2) {
$response->hasHeader('Content-Type')->willReturn(false);
}

return $response->reveal();
};

$creds->fetchAuthToken($httpHandler);
// any checks on the result are futile as they have been coded above
}
public function testCanBeUsedInAuthTokenMiddlewareWhenAnAudienceIsGiven()
{
$targetAudience = '123@456.com';
$jsonKey = $this->createISACTestJson();
$credentials = new ImpersonatedServiceAccountCredentials(null, $jsonKey, null, $targetAudience);

// 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);

// this handler is the actual handler that makes the authenticated request
$httpHandler = function (RequestInterface $request) use (&$requestCount) {
$this->assertTrue($request->hasHeader('authorization'));
$authHeader = $request->getHeader('authorization');
$this->assertCount(1, $authHeader);
$this->assertEquals('Bearer this.is.an.id.token', $authHeader[0]);
};

$middleware($httpHandler)(
new Request('GET', 'https://www.google.com'),
['auth' => 'google_auth']
);
}
}
1 change: 1 addition & 0 deletions tests/FetchAuthTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ public function provideMakeHttpClient()
['Google\Auth\Credentials\AppIdentityCredentials'],
['Google\Auth\Credentials\GCECredentials'],
['Google\Auth\Credentials\ServiceAccountCredentials'],
['Google\Auth\Credentials\ImpersonatedServiceAccountCredentials'],
['Google\Auth\Credentials\ServiceAccountJwtAccessCredentials'],
['Google\Auth\Credentials\UserRefreshCredentials'],
['Google\Auth\OAuth2'],
Expand Down
10 changes: 10 additions & 0 deletions tests/fixtures3/impersonated_service_account_credentials.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken",
"source_credentials": {
"client_id": "client123",
"client_secret": "clientSecret123",
"refresh_token": "refreshToken123",
"type": "authorized_user"
}
}