Skip to content

Commit

Permalink
feat: call IamCredentials endpoint for generating ID tokens outside G…
Browse files Browse the repository at this point in the history
…DU (#581)
  • Loading branch information
bshaffer authored Nov 5, 2024
1 parent 1601efc commit 2d7d03d
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 7 deletions.
44 changes: 39 additions & 5 deletions src/Credentials/ServiceAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@

namespace Google\Auth\Credentials;

use Firebase\JWT\JWT;
use Google\Auth\CredentialsLoader;
use Google\Auth\GetQuotaProjectInterface;
use Google\Auth\Iam;
use Google\Auth\OAuth2;
use Google\Auth\ProjectIdProviderInterface;
use Google\Auth\ServiceAccountSignerTrait;
Expand Down Expand Up @@ -71,6 +73,7 @@ class ServiceAccountCredentials extends CredentialsLoader implements
* @var string
*/
private const CRED_TYPE = 'sa';
private const IAM_SCOPE = 'https://www.googleapis.com/auth/iam';

/**
* The OAuth2 instance used to conduct authorization.
Expand Down Expand Up @@ -165,6 +168,7 @@ public function __construct(
'scope' => $scope,
'signingAlgorithm' => 'RS256',
'signingKey' => $jsonKey['private_key'],
'signingKeyId' => $jsonKey['private_key_id'] ?? null,
'sub' => $sub,
'tokenCredentialUri' => self::TOKEN_CREDENTIAL_URI,
'additionalClaims' => $additionalClaims,
Expand Down Expand Up @@ -213,9 +217,34 @@ public function fetchAuthToken(?callable $httpHandler = null)

return $accessToken;
}
$authRequestType = empty($this->auth->getAdditionalClaims()['target_audience'])
? 'at' : 'it';
return $this->auth->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], $authRequestType));

if ($this->isIdTokenRequest() && $this->getUniverseDomain() !== self::DEFAULT_UNIVERSE_DOMAIN) {
$now = time();
$jwt = Jwt::encode(
[
'iss' => $this->auth->getIssuer(),
'sub' => $this->auth->getIssuer(),
'scope' => self::IAM_SCOPE,
'exp' => ($now + $this->auth->getExpiry()),
'iat' => ($now - OAuth2::DEFAULT_SKEW_SECONDS),
],
$this->auth->getSigningKey(),
$this->auth->getSigningAlgorithm(),
$this->auth->getSigningKeyId()
);
// We create a new instance of Iam each time because the `$httpHandler` might change.
$idToken = (new Iam($httpHandler, $this->getUniverseDomain()))->generateIdToken(
$this->auth->getIssuer(),
$this->auth->getAdditionalClaims()['target_audience'],
$jwt,
$this->applyTokenEndpointMetrics([], 'it')
);
return ['id_token' => $idToken];
}
return $this->auth->fetchAuthToken(
$httpHandler,
$this->applyTokenEndpointMetrics([], $this->isIdTokenRequest() ? 'it' : 'at')
);
}

/**
Expand Down Expand Up @@ -399,8 +428,8 @@ private function useSelfSignedJwt()
return false;
}

// If claims are set, this call is for "id_tokens"
if ($this->auth->getAdditionalClaims()) {
// Do not use self-signed JWT for ID tokens
if ($this->isIdTokenRequest()) {
return false;
}

Expand All @@ -416,4 +445,9 @@ private function useSelfSignedJwt()

return is_null($this->auth->getScope());
}

private function isIdTokenRequest(): bool
{
return !empty($this->auth->getAdditionalClaims()['target_audience']);
}
}
49 changes: 47 additions & 2 deletions src/Iam.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Iam
const SIGN_BLOB_PATH = '%s:signBlob?alt=json';
const SERVICE_ACCOUNT_NAME = 'projects/-/serviceAccounts/%s';
private const IAM_API_ROOT_TEMPLATE = 'https://iamcredentials.UNIVERSE_DOMAIN/v1';
private const GENERATE_ID_TOKEN_PATH = '%s:generateIdToken';

/**
* @var callable
Expand Down Expand Up @@ -73,7 +74,6 @@ public function __construct(
*/
public function signBlob($email, $accessToken, $stringToSign, array $delegates = [])
{
$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::SIGN_BLOB_PATH, $name);
Expand Down Expand Up @@ -102,9 +102,54 @@ public function signBlob($email, $accessToken, $stringToSign, array $delegates =
Utils::streamFor(json_encode($body))
);

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

return $body['signedBlob'];
}

/**
* Sign a string using the IAM signBlob API.
*
* Note that signing using IAM requires your service account to have the
* `iam.serviceAccounts.signBlob` permission, part of the "Service Account
* Token Creator" IAM role.
*
* @param string $clientEmail The service account email.
* @param string $targetAudience The audience for the ID token.
* @param string $bearerToken The token to authenticate the IAM request.
* @param array<string, string> $headers [optional] Additional headers to send with the request.
*
* @return string The signed string, base64-encoded.
*/
public function generateIdToken(
string $clientEmail,
string $targetAudience,
string $bearerToken,
array $headers = []
): string {
$name = sprintf(self::SERVICE_ACCOUNT_NAME, $clientEmail);
$apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE);
$uri = $apiRoot . '/' . sprintf(self::GENERATE_ID_TOKEN_PATH, $name);

$headers['Authorization'] = 'Bearer ' . $bearerToken;

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

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

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

return $body['token'];
}
}
35 changes: 35 additions & 0 deletions tests/Credentials/ServiceAccountCredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Google\Auth\CredentialsLoader;
use Google\Auth\OAuth2;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
Expand Down Expand Up @@ -307,6 +308,40 @@ public function testShouldBeIdTokenWhenTargetAudienceIsSet()
$this->assertEquals(1, $timesCalled);
}

public function testShouldUseIamWhenTargetAudienceAndUniverseDomainIsSet()
{
$testJson = $this->createTestJson();
$testJson['universe_domain'] = 'abc.xyz';

$timesCalled = 0;
$httpHandler = function (Request $request) use (&$timesCalled) {
$timesCalled++;

// Verify Request
$this->assertStringContainsString(':generateIdToken', $request->getUri());
$json = json_decode($request->getBody(), true);
$this->assertArrayHasKey('audience', $json);
$this->assertEquals('a target audience', $json['audience']);

// Verify JWT Bearer Token
$jwt = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
list($header, $payload, $sig) = explode('.', $jwt);
$jwtParams = json_decode(base64_decode($payload), true);
$this->assertArrayHasKey('iss', $jwtParams);
$this->assertEquals('test@example.com', $jwtParams['iss']);

// Verify header contains the auth headers
$parts = explode(' ', $request->getHeaderLine('x-goog-api-client'));
$this->assertContains('auth-request-type/it', $parts);

// return expected IAM ID token response
return new Psr7\Response(200, [], json_encode(['token' => 'idtoken12345']));
};
$sa = new ServiceAccountCredentials(null, $testJson, null, 'a target audience');
$this->assertEquals('idtoken12345', $sa->fetchAuthToken($httpHandler)['id_token']);
$this->assertEquals(1, $timesCalled);
}

public function testShouldBeOAuthRequestWhenSubIsSet()
{
$testJson = $this->createTestJson();
Expand Down

0 comments on commit 2d7d03d

Please sign in to comment.