Skip to content

Commit

Permalink
feat: Allow Impersonated SA Credentials to make requests authenticate…
Browse files Browse the repository at this point in the history
…d with an IdToken

feat: Allow Impersonated SA Credentials to make requests authenticated with an IdToken

chore: Removed uncommented code
  • Loading branch information
gjvanahee committed Sep 20, 2024
1 parent e10dc3f commit 48554e6
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 24 deletions.
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"
}
}

0 comments on commit 48554e6

Please sign in to comment.