From 2d7d03d0cac08c8d6e03276f14ef260ccb980b7c Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 5 Nov 2024 11:08:05 -0800 Subject: [PATCH] feat: call IamCredentials endpoint for generating ID tokens outside GDU (#581) --- src/Credentials/ServiceAccountCredentials.php | 44 +++++++++++++++-- src/Iam.php | 49 ++++++++++++++++++- .../ServiceAccountCredentialsTest.php | 35 +++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 2053b942d..c13b22921 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -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; @@ -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. @@ -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, @@ -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') + ); } /** @@ -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; } @@ -416,4 +445,9 @@ private function useSelfSignedJwt() return is_null($this->auth->getScope()); } + + private function isIdTokenRequest(): bool + { + return !empty($this->auth->getAdditionalClaims()['target_audience']); + } } diff --git a/src/Iam.php b/src/Iam.php index 0abe6331d..b32ac6065 100644 --- a/src/Iam.php +++ b/src/Iam.php @@ -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 @@ -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); @@ -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 $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']; + } } diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 4352af154..63110448e 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -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; @@ -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();