Skip to content

Commit

Permalink
feat: add ImpersonatedServiceAccountCredentials (#421)
Browse files Browse the repository at this point in the history
  • Loading branch information
PsyonixMonroe committed Nov 28, 2022
1 parent d3726fe commit de766e9
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 41 deletions.
43 changes: 3 additions & 40 deletions src/Credentials/GCECredentials.php
Expand Up @@ -22,6 +22,7 @@
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use Google\Auth\Iam;
use Google\Auth\IamSignerTrait;
use Google\Auth\ProjectIdProviderInterface;
use Google\Auth\SignBlobInterface;
use GuzzleHttp\Exception\ClientException;
Expand Down Expand Up @@ -60,6 +61,8 @@ class GCECredentials extends CredentialsLoader implements
ProjectIdProviderInterface,
GetQuotaProjectInterface
{
use IamSignerTrait;

// phpcs:disable
const cacheKey = 'GOOGLE_AUTH_PHP_GCE';
// phpcs:enable
Expand Down Expand Up @@ -141,11 +144,6 @@ class GCECredentials extends CredentialsLoader implements
*/
private $projectId;

/**
* @var Iam|null
*/
private $iam;

/**
* @var string
*/
Expand Down Expand Up @@ -451,41 +449,6 @@ public function getClientName(callable $httpHandler = null)
return $this->clientName;
}

/**
* Sign a string using the default service account private key.
*
* This implementation uses IAM's signBlob API.
*
* @see https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/signBlob SignBlob
*
* @param string $stringToSign The string to sign.
* @param bool $forceOpenSsl [optional] Does not apply to this credentials
* type.
* @param string $accessToken The access token to use to sign the blob. If
* provided, saves a call to the metadata server for a new access
* token. **Defaults to** `null`.
* @return string
*/
public function signBlob($stringToSign, $forceOpenSsl = false, $accessToken = null)
{
$httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());

// Providing a signer is useful for testing, but it's undocumented
// because it's not something a user would generally need to do.
$signer = $this->iam ?: new Iam($httpHandler);

$email = $this->getClientName($httpHandler);

if (is_null($accessToken)) {
$previousToken = $this->getLastReceivedToken();
$accessToken = $previousToken
? $previousToken['access_token']
: $this->fetchAuthToken($httpHandler)['access_token'];
}

return $signer->signBlob($email, $accessToken, $stringToSign);
}

/**
* Fetch the default Project ID from compute engine.
*
Expand Down
132 changes: 132 additions & 0 deletions src/Credentials/ImpersonatedServiceAccountCredentials.php
@@ -0,0 +1,132 @@
<?php

/*
* Copyright 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Google\Auth\Credentials;

use Google\Auth\CredentialsLoader;
use Google\Auth\IamSignerTrait;
use Google\Auth\SignBlobInterface;

class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements SignBlobInterface
{
use IamSignerTrait;

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

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

/**
* 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|array<mixed> $jsonKey JSON credential file path or JSON credentials
* as an associative array
*/
public function __construct(
$scope,
$jsonKey
) {
if (is_string($jsonKey)) {
if (!file_exists($jsonKey)) {
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');
}
}
if (!array_key_exists('service_account_impersonation_url', $jsonKey)) {
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');
}

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

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

/**
* Helper function for extracting the Server Account Name from the URL saved in the account credentials file
* @param $serviceAccountImpersonationUrl string URL from the 'service_account_impersonation_url' field
* @return string Service account email or ID.
*/
private function getImpersonatedServiceAccountNameFromUrl(string $serviceAccountImpersonationUrl)
{
$fields = explode('/', $serviceAccountImpersonationUrl);
$lastField = end($fields);
$splitter = explode(':', $lastField);
return $splitter[0];
}

/**
* Get the client name from the keyfile
*
* In this implementation, it will return the issuers email from the oauth token.
*
* @param callable|null $unusedHttpHandler not used by this credentials type.
* @return string Token issuer email
*/
public function getClientName(callable $unusedHttpHandler = null)
{
return $this->impersonatedServiceAccountName;
}

/**
* @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
* }
*/
public function fetchAuthToken(callable $httpHandler = null)
{
return $this->sourceCredentials->fetchAuthToken($httpHandler);
}

/**
* @return string
*/
public function getCacheKey()
{
return $this->sourceCredentials->getCacheKey();
}

/**
* @return array<mixed>
*/
public function getLastReceivedToken()
{
return $this->sourceCredentials->getLastReceivedToken();
}
}
8 changes: 7 additions & 1 deletion src/CredentialsLoader.php
Expand Up @@ -17,6 +17,7 @@

namespace Google\Auth;

use Google\Auth\Credentials\ImpersonatedServiceAccountCredentials;
use Google\Auth\Credentials\InsecureCredentials;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Google\Auth\Credentials\UserRefreshCredentials;
Expand Down Expand Up @@ -120,7 +121,7 @@ public static function fromWellKnownFile()
* user-defined scopes exist, expressed either as an Array or as a
* space-delimited string.
*
* @return ServiceAccountCredentials|UserRefreshCredentials
* @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials
*/
public static function makeCredentials(
$scope,
Expand All @@ -141,6 +142,11 @@ public static function makeCredentials(
return new UserRefreshCredentials($anyScope, $jsonKey);
}

if ($jsonKey['type'] == 'impersonated_service_account') {
$anyScope = $scope ?: $defaultScope;
return new ImpersonatedServiceAccountCredentials($anyScope, $jsonKey);
}

throw new \InvalidArgumentException('invalid value in the type field');
}

Expand Down
67 changes: 67 additions & 0 deletions src/IamSignerTrait.php
@@ -0,0 +1,67 @@
<?php

/*
* Copyright 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Google\Auth;

use Exception;
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;

trait IamSignerTrait
{
/**
* @var Iam|null
*/
private $iam;

/**
* Sign a string using the default service account private key.
*
* This implementation uses IAM's signBlob API.
*
* @see https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/signBlob SignBlob
*
* @param string $stringToSign The string to sign.
* @param bool $forceOpenSsl [optional] Does not apply to this credentials
* type.
* @param string $accessToken The access token to use to sign the blob. If
* provided, saves a call to the metadata server for a new access
* token. **Defaults to** `null`.
* @return string
* @throws Exception
*/
public function signBlob($stringToSign, $forceOpenSsl = false, $accessToken = null)
{
$httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient());

// Providing a signer is useful for testing, but it's undocumented
// because it's not something a user would generally need to do.
$signer = $this->iam ?: new Iam($httpHandler);

$email = $this->getClientName($httpHandler);

if (is_null($accessToken)) {
$previousToken = $this->getLastReceivedToken();
$accessToken = $previousToken
? $previousToken['access_token']
: $this->fetchAuthToken($httpHandler)['access_token'];
}

return $signer->signBlob($email, $accessToken, $stringToSign);
}
}
27 changes: 27 additions & 0 deletions tests/ApplicationDefaultCredentialsTest.php
Expand Up @@ -159,6 +159,33 @@ public function testGceCredentials()
$this->assertStringContainsString('a+user+scope', $tokenUri);
}

public function testImpersonatedServiceAccountCredentials()
{
putenv('HOME=' . __DIR__ . '/fixtures5');
$creds = ApplicationDefaultCredentials::getCredentials(
null,
null,
null,
null,
null,
'a default scope'
);
$this->assertInstanceOf(
'Google\Auth\Credentials\ImpersonatedServiceAccountCredentials',
$creds);

$this->assertEquals('service_account_name@namespace.iam.gserviceaccount.com', $creds->getClientName());

$sourceCredentialsProperty = (new ReflectionClass($creds))->getProperty('sourceCredentials');
$sourceCredentialsProperty->setAccessible(true);

// used default scope
$sourceCredentials = $sourceCredentialsProperty->getValue($creds);
$this->assertInstanceOf(
'Google\Auth\Credentials\UserRefreshCredentials',
$sourceCredentials);
}

/** @runInSeparateProcess */
public function testUserRefreshCredentials()
{
Expand Down

0 comments on commit de766e9

Please sign in to comment.