From 9799903c033f5aa43828cf7f0c1bb479234c8dfb Mon Sep 17 00:00:00 2001 From: yash30201 <54198301+yash30201@users.noreply.github.com> Date: Thu, 14 Sep 2023 04:18:20 +0000 Subject: [PATCH 01/14] feat: Add Metrics * Add Metrics Trait * Add unit tests for metrics trait --- src/MetricsTrait.php | 144 +++++++++++++++++++++++++++++++++++++ tests/MetricsTraitTest.php | 118 ++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/MetricsTrait.php create mode 100644 tests/MetricsTraitTest.php diff --git a/src/MetricsTrait.php b/src/MetricsTrait.php new file mode 100644 index 000000000..3d5488b4e --- /dev/null +++ b/src/MetricsTrait.php @@ -0,0 +1,144 @@ +getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_MDS; + } + + /** + * Returns header string for token request with Service Account Credentials. + * + * @param bool $isAccessTokenRequest Determins whether the request is + * for access token or identity token. `true` returns headers + * for access token and `false` returns for identity tokens. + * @return string + */ + public function getTokenRequestSaAssertionHeader(bool $isAccessTokenRequest) + { + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_ASSERTION; + } + + /** + * Returns header string for token request with Impersonated Service Account Credentials. + * + * @param bool $isAccessTokenRequest Determins whether the request is + * for access token or identity token. `true` returns headers + * for access token and `false` returns for identity tokens. + * @return string + */ + public function getTokenRequestSaImpersonateHeader(bool $isAccessTokenRequest) + { + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_IMPERSONATE; + } + + /** + * Returns header string for token request with User Refresh Credentials. + * + * @param bool $isAccessTokenRequest Determins whether the request is + * for access token or identity token. `true` returns headers + * for access token and `false` returns for identity tokens. + * @return string + */ + public function getTokenRequestUserHeader(bool $isAccessTokenRequest) + { + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_USER; + } + + /** + * Returns header string for metadata server ping request. + */ + public function getMdsPingHeader() + { + return $this->getPhpAndAuthLibVersion() + . ' ' . self::REQUEST_TYPE_MDS_PING; + } + + /** + * Apply the auth metrics header to `x-goog-api-client` key of the `$headers` + * properly and return updated headers. + * + * @param array $headers The headers to update. + * @param string $metricsHeaderToApply Auth metrics header value to apply + * @return array Updated headers value. + */ + public function applyAuthMetricsHeaders(array $headers, string $metricsHeaderToApply) + { + if ($metricsHeaderToApply == '') { + return $headers; + } else if (isset($headers[self::METRICS_HEADER_KEY])) { + $headers[self::METRICS_HEADER_KEY][0] .= ' ' . $metricsHeaderToApply; + } else { + $headers[self::METRICS_HEADER_KEY] = [$metricsHeaderToApply]; + } + return $headers; + } + + private function getDefaults(bool $forAccessToken = true) + { + $result = $this->getPhpAndAuthLibVersion(); + if ($forAccessToken) { + $result .= ' ' . self::REQUEST_TYPE_ACCESS_TOKEN; + } else { + $result .= ' ' . self::REQUEST_TYPE_ID_TOKEN; + } + return $result; + } + +} diff --git a/tests/MetricsTraitTest.php b/tests/MetricsTraitTest.php new file mode 100644 index 000000000..28d4975b3 --- /dev/null +++ b/tests/MetricsTraitTest.php @@ -0,0 +1,118 @@ +impl = new MetricsTraitImplementation(); + $this->phpAndAuthVersion = 'gl-php/' . PHP_VERSION + . ' auth/' . self::VERSION; + $this->defaultForAccessTokenRequest = $this->phpAndAuthVersion + . ' ' . $this->impl::REQUEST_TYPE_ACCESS_TOKEN; + $this->defaultForIdTokenRequest = $this->phpAndAuthVersion + . ' ' . $this->impl::REQUEST_TYPE_ID_TOKEN; + } + + public function testGetPhpAndAuthLibVersion() + { + $this->assertEquals( + $this->phpAndAuthVersion, + $this->impl->getPhpAndAuthLibVersion() + ); + } + + public function testGetDefaults() + { + // For access token + $this->assertEquals( + $this->defaultForAccessTokenRequest, + $this->impl->getDefaults($isAccessTokenRequest = true) + ); + + // For identity token + $this->assertEquals( + $this->defaultForIdTokenRequest, + $this->impl->getDefaults($isAccessTokenRequest = false) + ); + } + + /** + * @dataProvider getTokenRequestHeaderCases + * @param bool $isAccessTokenRequest + * @param string $credType + * @param string $expected + */ + public function testGetTokenRequestHeaders( + bool $isAccessTokenRequest, + string $credType, + string $expected + ) { + $defaultHeader = $isAccessTokenRequest ? + $this->defaultForAccessTokenRequest : + $this->defaultForIdTokenRequest; + $expectedResult = $defaultHeader . ' ' . $expected; + + $testMethodName = 'getTokenRequest' . $credType . 'Header'; + $this->assertEquals( + $expectedResult, + $this->impl->$testMethodName($isAccessTokenRequest) + ); + } + + public function testGetMdsPingHeader() + { + $this->assertEquals( + $this->phpAndAuthVersion . ' ' . $this->impl::REQUEST_TYPE_MDS_PING, + $this->impl->getMdsPingHeader() + ); + } + + public function getTokenRequestHeaderCases() + { + $impl = new MetricsTraitImplementation(); + return [ + [true, 'Mds', $impl::CRED_TYPE_SA_MDS], + [false, 'Mds', $impl::CRED_TYPE_SA_MDS], + [true, 'SaAssertion', $impl::CRED_TYPE_SA_ASSERTION], + [false, 'SaAssertion', $impl::CRED_TYPE_SA_ASSERTION], + [true, 'SaImpersonate', $impl::CRED_TYPE_SA_IMPERSONATE], + [false, 'SaImpersonate', $impl::CRED_TYPE_SA_IMPERSONATE], + [true, 'User', $impl::CRED_TYPE_USER], + [false, 'User', $impl::CRED_TYPE_USER] + ]; + } +} + +class MetricsTraitImplementation +{ + use MetricsTrait { + getDefaults as public; + } +} From 5caf63affdffe6c96383837972b8975e454dc633 Mon Sep 17 00:00:00 2001 From: yash30201 <54198301+yash30201@users.noreply.github.com> Date: Thu, 14 Sep 2023 04:49:05 +0000 Subject: [PATCH 02/14] Modify constants to public static variables --- src/MetricsTrait.php | 43 +++++++++++++++++++------------------- tests/MetricsTraitTest.php | 22 +++++++++---------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/MetricsTrait.php b/src/MetricsTrait.php index 3d5488b4e..cafad35ec 100644 --- a/src/MetricsTrait.php +++ b/src/MetricsTrait.php @@ -24,28 +24,27 @@ */ trait MetricsTrait { - public const METRICS_HEADER_KEY = 'x-goog-api-client'; + public static $metricsHeaderKey = 'x-goog-api-client'; // Auth request type - public const REQUEST_TYPE_ACCESS_TOKEN = 'auth-request-type/at'; - public const REQUEST_TYPE_ID_TOKEN = 'auth-request-type/it'; - public const REQUEST_TYPE_MDS_PING = 'auth-request-type/mds'; - public const REQUEST_TYPE_REAUTH_START = 'auth-request-type/re-start'; + public static $requestTypeAccessToken = 'auth-request-type/at'; + public static $requestTypeIdToken = 'auth-request-type/it'; + public static $requestTypeMdsPing = 'auth-request-type/mds'; // Credential type - public const CRED_TYPE_USER = 'cred-type/u'; - public const CRED_TYPE_SA_ASSERTION = 'cred-type/sa'; - public const CRED_TYPE_SA_JWT = 'cred-type/jwt'; - public const CRED_TYPE_SA_MDS = 'cred-type/mds'; - public const CRED_TYPE_SA_IMPERSONATE = 'cred-type/imp'; + public static $credTypeUser = 'cred-type/u'; + public static $credTypeSaAssertion = 'cred-type/sa'; + public static $credTypeSaJwt = 'cred-type/jwt'; + public static $credTypeSaMds = 'cred-type/mds'; + public static $credTypeSaImpersonate = 'cred-type/imp'; // TODO: Find a way to get the auth version // Auth library version - public const VERSION = '10.0.0'; + public static $version = '10.0.0'; public function getPhpAndAuthLibVersion() { - return 'gl-php/' . PHP_VERSION . ' auth/' . self::VERSION; + return 'gl-php/' . PHP_VERSION . ' auth/' . self::$version; } /** @@ -59,7 +58,7 @@ public function getPhpAndAuthLibVersion() */ public function getTokenRequestMdsHeader(bool $isAccessTokenRequest) { - return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_MDS; + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::$credTypeSaMds; } /** @@ -72,7 +71,7 @@ public function getTokenRequestMdsHeader(bool $isAccessTokenRequest) */ public function getTokenRequestSaAssertionHeader(bool $isAccessTokenRequest) { - return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_ASSERTION; + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::$credTypeSaAssertion; } /** @@ -85,7 +84,7 @@ public function getTokenRequestSaAssertionHeader(bool $isAccessTokenRequest) */ public function getTokenRequestSaImpersonateHeader(bool $isAccessTokenRequest) { - return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_SA_IMPERSONATE; + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::$credTypeSaImpersonate; } /** @@ -98,7 +97,7 @@ public function getTokenRequestSaImpersonateHeader(bool $isAccessTokenRequest) */ public function getTokenRequestUserHeader(bool $isAccessTokenRequest) { - return $this->getDefaults($isAccessTokenRequest) . ' ' . self::CRED_TYPE_USER; + return $this->getDefaults($isAccessTokenRequest) . ' ' . self::$credTypeUser; } /** @@ -107,7 +106,7 @@ public function getTokenRequestUserHeader(bool $isAccessTokenRequest) public function getMdsPingHeader() { return $this->getPhpAndAuthLibVersion() - . ' ' . self::REQUEST_TYPE_MDS_PING; + . ' ' . self::$requestTypeMdsPing; } /** @@ -122,10 +121,10 @@ public function applyAuthMetricsHeaders(array $headers, string $metricsHeaderToA { if ($metricsHeaderToApply == '') { return $headers; - } else if (isset($headers[self::METRICS_HEADER_KEY])) { - $headers[self::METRICS_HEADER_KEY][0] .= ' ' . $metricsHeaderToApply; + } else if (isset($headers[self::$metricsHeaderKey])) { + $headers[self::$metricsHeaderKey][0] .= ' ' . $metricsHeaderToApply; } else { - $headers[self::METRICS_HEADER_KEY] = [$metricsHeaderToApply]; + $headers[self::$metricsHeaderKey] = [$metricsHeaderToApply]; } return $headers; } @@ -134,9 +133,9 @@ private function getDefaults(bool $forAccessToken = true) { $result = $this->getPhpAndAuthLibVersion(); if ($forAccessToken) { - $result .= ' ' . self::REQUEST_TYPE_ACCESS_TOKEN; + $result .= ' ' . self::$requestTypeAccessToken; } else { - $result .= ' ' . self::REQUEST_TYPE_ID_TOKEN; + $result .= ' ' . self::$requestTypeIdToken; } return $result; } diff --git a/tests/MetricsTraitTest.php b/tests/MetricsTraitTest.php index 28d4975b3..72e1ae4f6 100644 --- a/tests/MetricsTraitTest.php +++ b/tests/MetricsTraitTest.php @@ -35,9 +35,9 @@ public function setUp(): void $this->phpAndAuthVersion = 'gl-php/' . PHP_VERSION . ' auth/' . self::VERSION; $this->defaultForAccessTokenRequest = $this->phpAndAuthVersion - . ' ' . $this->impl::REQUEST_TYPE_ACCESS_TOKEN; + . ' ' . $this->impl::$requestTypeAccessToken; $this->defaultForIdTokenRequest = $this->phpAndAuthVersion - . ' ' . $this->impl::REQUEST_TYPE_ID_TOKEN; + . ' ' . $this->impl::$requestTypeIdToken; } public function testGetPhpAndAuthLibVersion() @@ -89,7 +89,7 @@ public function testGetTokenRequestHeaders( public function testGetMdsPingHeader() { $this->assertEquals( - $this->phpAndAuthVersion . ' ' . $this->impl::REQUEST_TYPE_MDS_PING, + $this->phpAndAuthVersion . ' ' . $this->impl::$requestTypeMdsPing, $this->impl->getMdsPingHeader() ); } @@ -98,14 +98,14 @@ public function getTokenRequestHeaderCases() { $impl = new MetricsTraitImplementation(); return [ - [true, 'Mds', $impl::CRED_TYPE_SA_MDS], - [false, 'Mds', $impl::CRED_TYPE_SA_MDS], - [true, 'SaAssertion', $impl::CRED_TYPE_SA_ASSERTION], - [false, 'SaAssertion', $impl::CRED_TYPE_SA_ASSERTION], - [true, 'SaImpersonate', $impl::CRED_TYPE_SA_IMPERSONATE], - [false, 'SaImpersonate', $impl::CRED_TYPE_SA_IMPERSONATE], - [true, 'User', $impl::CRED_TYPE_USER], - [false, 'User', $impl::CRED_TYPE_USER] + [true, 'Mds', $impl::$credTypeSaMds], + [false, 'Mds', $impl::$credTypeSaMds], + [true, 'SaAssertion', $impl::$credTypeSaAssertion], + [false, 'SaAssertion', $impl::$credTypeSaAssertion], + [true, 'SaImpersonate', $impl::$credTypeSaImpersonate], + [false, 'SaImpersonate', $impl::$credTypeSaImpersonate], + [true, 'User', $impl::$credTypeUser], + [false, 'User', $impl::$credTypeUser] ]; } } From 9fb009b677ac75a4e31d3d97a97ac318482a2be1 Mon Sep 17 00:00:00 2001 From: yash30201 <54198301+yash30201@users.noreply.github.com> Date: Thu, 14 Sep 2023 04:57:20 +0000 Subject: [PATCH 03/14] fix` else if` -> `elseif` --- src/MetricsTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MetricsTrait.php b/src/MetricsTrait.php index cafad35ec..b88b12b88 100644 --- a/src/MetricsTrait.php +++ b/src/MetricsTrait.php @@ -121,7 +121,7 @@ public function applyAuthMetricsHeaders(array $headers, string $metricsHeaderToA { if ($metricsHeaderToApply == '') { return $headers; - } else if (isset($headers[self::$metricsHeaderKey])) { + } elseif (isset($headers[self::$metricsHeaderKey])) { $headers[self::$metricsHeaderKey][0] .= ' ' . $metricsHeaderToApply; } else { $headers[self::$metricsHeaderKey] = [$metricsHeaderToApply]; From e5bc8979bf87159d9acab1ca8cb7cd7af008b2a6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 5 Oct 2023 13:08:26 -0700 Subject: [PATCH 04/14] feat: add AWS credential source (#474) --- src/CredentialSource/AwsNativeSource.php | 360 +++++++++++++++++ .../ExternalAccountCredentials.php | 110 +++++- tests/ApplicationDefaultCredentialsTest.php | 1 + .../CredentialSource/AwsNativeSourceTest.php | 371 ++++++++++++++++++ .../ExternalAccountCredentialsTest.php | 223 ++++++++++- tests/fixtures6/aws_credentials.json | 13 + 6 files changed, 1065 insertions(+), 13 deletions(-) create mode 100644 src/CredentialSource/AwsNativeSource.php create mode 100644 tests/CredentialSource/AwsNativeSourceTest.php create mode 100644 tests/fixtures6/aws_credentials.json diff --git a/src/CredentialSource/AwsNativeSource.php b/src/CredentialSource/AwsNativeSource.php new file mode 100644 index 000000000..3a8c20eaa --- /dev/null +++ b/src/CredentialSource/AwsNativeSource.php @@ -0,0 +1,360 @@ +audience = $audience; + $this->regionalCredVerificationUrl = $regionalCredVerificationUrl; + $this->regionUrl = $regionUrl; + $this->securityCredentialsUrl = $securityCredentialsUrl; + $this->imdsv2SessionTokenUrl = $imdsv2SessionTokenUrl; + } + + public function fetchSubjectToken(callable $httpHandler = null): string + { + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + + $headers = []; + if ($this->imdsv2SessionTokenUrl) { + $headers = [ + 'X-aws-ec2-metadata-token' => self::getImdsV2SessionToken($this->imdsv2SessionTokenUrl, $httpHandler) + ]; + } + + if (!$signingVars = self::getSigningVarsFromEnv()) { + if (!$this->securityCredentialsUrl) { + throw new \LogicException('Unable to get credentials from ENV, and no security credentials URL provided'); + } + $signingVars = self::getSigningVarsFromUrl( + $httpHandler, + $this->securityCredentialsUrl, + self::getRoleName($httpHandler, $this->securityCredentialsUrl, $headers), + $headers + ); + } + + if (!$region = self::getRegionFromEnv()) { + if (!$this->regionUrl) { + throw new \LogicException('Unable to get region from ENV, and no region URL provided'); + } + $region = self::getRegionFromUrl($httpHandler, $this->regionUrl, $headers); + } + $url = str_replace('{region}', $region, $this->regionalCredVerificationUrl); + $host = parse_url($url)['host'] ?? ''; + + // From here we use the signing vars to create the signed request to receive a token + [$accessKeyId, $secretAccessKey, $securityToken] = $signingVars; + $headers = self::getSignedRequestHeaders($region, $host, $accessKeyId, $secretAccessKey, $securityToken); + + // Inject x-goog-cloud-target-resource into header + $headers['x-goog-cloud-target-resource'] = $this->audience; + + // Format headers as they're expected in the subject token + $formattedHeaders= array_map( + fn ($k, $v) => ['key' => $k, 'value' => $v], + array_keys($headers), + $headers, + ); + + $request = [ + 'headers' => $formattedHeaders, + 'method' => 'POST', + 'url' => $url, + ]; + + return urlencode(json_encode($request) ?: ''); + } + + /** + * @internal + */ + public static function getImdsV2SessionToken(string $imdsV2Url, callable $httpHandler): string + { + $headers = [ + 'X-aws-ec2-metadata-token-ttl-seconds' => '21600' + ]; + $request = new Request( + 'PUT', + $imdsV2Url, + $headers + ); + + $response = $httpHandler($request); + return (string) $response->getBody(); + } + + /** + * @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + * + * @internal + * + * @return array + */ + public static function getSignedRequestHeaders( + string $region, + string $host, + string $accessKeyId, + string $secretAccessKey, + ?string $securityToken + ): array { + $service = 'sts'; + + # Create a date for headers and the credential string in ISO-8601 format + $amzdate = date('Ymd\THis\Z'); + $datestamp = date('Ymd'); # Date w/o time, used in credential scope + + # Create the canonical headers and signed headers. Header names + # must be trimmed and lowercase, and sorted in code point order from + # low to high. Note that there is a trailing \n. + $canonicalHeaders = sprintf("host:%s\nx-amz-date:%s\n", $host, $amzdate); + if ($securityToken) { + $canonicalHeaders .= sprintf("x-amz-security-token:%s\n", $securityToken); + } + + # Step 5: Create the list of signed headers. This lists the headers + # in the canonicalHeaders list, delimited with ";" and in alpha order. + # Note: The request can include any headers; $canonicalHeaders and + # $signedHeaders lists those that you want to be included in the + # hash of the request. "Host" and "x-amz-date" are always required. + $signedHeaders = 'host;x-amz-date'; + if ($securityToken) { + $signedHeaders .= ';x-amz-security-token'; + } + + # Step 6: Create payload hash (hash of the request body content). For GET + # requests, the payload is an empty string (""). + $payloadHash = hash('sha256', ''); + + # Step 7: Combine elements to create canonical request + $canonicalRequest = implode("\n", [ + 'POST', // method + '/', // canonical URL + self::CRED_VERIFICATION_QUERY, // query string + $canonicalHeaders, + $signedHeaders, + $payloadHash + ]); + + # ************* TASK 2: CREATE THE STRING TO SIGN************* + # Match the algorithm to the hashing algorithm you use, either SHA-1 or + # SHA-256 (recommended) + $algorithm = 'AWS4-HMAC-SHA256'; + $scope = implode('/', [$datestamp, $region, $service, 'aws4_request']); + $stringToSign = implode("\n", [$algorithm, $amzdate, $scope, hash('sha256', $canonicalRequest)]); + + # ************* TASK 3: CALCULATE THE SIGNATURE ************* + # Create the signing key using the function defined above. + // (done above) + $signingKey = self::getSignatureKey($secretAccessKey, $datestamp, $region, $service); + + # Sign the string_to_sign using the signing_key + $signature = bin2hex(self::hmacSign($signingKey, $stringToSign)); + + # ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST ************* + # The signing information can be either in a query string value or in + # a header named Authorization. This code shows how to use a header. + # Create authorization header and add to request headers + $authorizationHeader = sprintf( + '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', + $algorithm, + $accessKeyId, + $scope, + $signedHeaders, + $signature + ); + + # The request can include any headers, but MUST include "host", "x-amz-date", + # and (for this scenario) "Authorization". "host" and "x-amz-date" must + # be included in the canonical_headers and signed_headers, as noted + # earlier. Order here is not significant. + $headers = [ + 'host' => $host, + 'x-amz-date' => $amzdate, + 'Authorization' => $authorizationHeader, + ]; + if ($securityToken) { + $headers['x-amz-security-token'] = $securityToken; + } + + return $headers; + } + + /** + * @internal + */ + public static function getRegionFromEnv(): ?string + { + $region = getenv('AWS_REGION'); + if (empty($region)) { + $region = getenv('AWS_DEFAULT_REGION'); + } + return $region ?: null; + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $regionUrl + * @param array $headers Request headers to send in with the request. + */ + public static function getRegionFromUrl(callable $httpHandler, string $regionUrl, array $headers): string + { + // get the region/zone from the region URL + $regionRequest = new Request('GET', $regionUrl, $headers); + $regionResponse = $httpHandler($regionRequest); + + // Remove last character. For example, if us-east-2b is returned, + // the region would be us-east-2. + return substr((string) $regionResponse->getBody(), 0, -1); + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $securityCredentialsUrl + * @param array $headers Request headers to send in with the request. + */ + public static function getRoleName(callable $httpHandler, string $securityCredentialsUrl, array $headers): string + { + // Get the AWS role name + $roleRequest = new Request('GET', $securityCredentialsUrl, $headers); + $roleResponse = $httpHandler($roleRequest); + $roleName = (string) $roleResponse->getBody(); + + return $roleName; + } + + /** + * @internal + * + * @param callable $httpHandler + * @param string $securityCredentialsUrl + * @param array $headers Request headers to send in with the request. + * @return array{string, string, ?string} + */ + public static function getSigningVarsFromUrl( + callable $httpHandler, + string $securityCredentialsUrl, + string $roleName, + array $headers + ): array { + // Get the AWS credentials + $credsRequest = new Request( + 'GET', + $securityCredentialsUrl . '/' . $roleName, + $headers + ); + $credsResponse = $httpHandler($credsRequest); + $awsCreds = json_decode((string) $credsResponse->getBody(), true); + return [ + $awsCreds['AccessKeyId'], // accessKeyId + $awsCreds['SecretAccessKey'], // secretAccessKey + $awsCreds['Token'], // token + ]; + } + + /** + * @internal + * + * @return array{string, string, ?string} + */ + public static function getSigningVarsFromEnv(): ?array + { + $accessKeyId = getenv('AWS_ACCESS_KEY_ID'); + $secretAccessKey = getenv('AWS_SECRET_ACCESS_KEY'); + if ($accessKeyId && $secretAccessKey) { + return [ + $accessKeyId, + $secretAccessKey, + getenv('AWS_SESSION_TOKEN') ?: null, // session token (can be null) + ]; + } + + return null; + } + + /** + * Return HMAC hash in binary string + */ + private static function hmacSign(string $key, string $msg): string + { + return hash_hmac('sha256', self::utf8Encode($msg), $key, true); + } + + /** + * @TODO add a fallback when mbstring is not available + */ + private static function utf8Encode(string $string): string + { + return mb_convert_encoding($string, 'UTF-8', 'ISO-8859-1'); + } + + private static function getSignatureKey( + string $key, + string $dateStamp, + string $regionName, + string $serviceName + ): string { + $kDate = self::hmacSign(self::utf8Encode('AWS4' . $key), $dateStamp); + $kRegion = self::hmacSign($kDate, $regionName); + $kService = self::hmacSign($kRegion, $serviceName); + $kSigning = self::hmacSign($kService, 'aws4_request'); + + return $kSigning; + } +} diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 8461b276b..b2716bfaa 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -17,22 +17,29 @@ namespace Google\Auth\Credentials; +use Google\Auth\CredentialSource\AwsNativeSource; use Google\Auth\CredentialSource\FileSource; use Google\Auth\CredentialSource\UrlSource; use Google\Auth\ExternalAccountCredentialSourceInterface; use Google\Auth\FetchAuthTokenInterface; +use Google\Auth\GetQuotaProjectInterface; +use Google\Auth\HttpHandler\HttpClientCache; +use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Auth\OAuth2; use Google\Auth\UpdateMetadataInterface; use Google\Auth\UpdateMetadataTrait; +use GuzzleHttp\Psr7\Request; use InvalidArgumentException; -class ExternalAccountCredentials implements FetchAuthTokenInterface, UpdateMetadataInterface +class ExternalAccountCredentials implements FetchAuthTokenInterface, UpdateMetadataInterface, GetQuotaProjectInterface { use UpdateMetadataTrait; private const EXTERNAL_ACCOUNT_TYPE = 'external_account'; private OAuth2 $auth; + private ?string $quotaProject; + private ?string $serviceAccountImpersonationUrl; /** * @param string|string[] $scope The scope of the access request, expressed either as an array @@ -78,6 +85,12 @@ public function __construct( ); } + if (array_key_exists('service_account_impersonation_url', $jsonKey)) { + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; + } + + $this->quotaProject = $jsonKey['quota_project_id'] ?? null; + $this->auth = new OAuth2([ 'tokenCredentialUri' => $jsonKey['token_url'], 'audience' => $jsonKey['audience'], @@ -101,6 +114,35 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr ); } + if ( + isset($credentialSource['environment_id']) + && 1 === preg_match('/^aws(\d+)$/', $credentialSource['environment_id'], $matches) + ) { + if ($matches[1] !== '1') { + throw new InvalidArgumentException( + "aws version \"$matches[1]\" is not supported in the current build." + ); + } + if (!array_key_exists('regional_cred_verification_url', $credentialSource)) { + throw new InvalidArgumentException( + 'The regional_cred_verification_url field is required for aws1 credential source.' + ); + } + if (!array_key_exists('audience', $jsonKey)) { + throw new InvalidArgumentException( + 'aws1 credential source requires an audience to be set in the JSON file.' + ); + } + + return new AwsNativeSource( + $jsonKey['audience'], + $credentialSource['regional_cred_verification_url'], // $regionalCredVerificationUrl + $credentialSource['region_url'] ?? null, // $regionUrl + $credentialSource['url'] ?? null, // $securityCredentialsUrl + $credentialSource['imdsv2_session_token_url'] ?? null, // $imdsV2TokenUrl + ); + } + if (isset($credentialSource['url'])) { return new UrlSource( $credentialSource['url'], @@ -112,6 +154,46 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr throw new InvalidArgumentException('Unable to determine credential source from json key.'); } + /** + * @param string $stsToken + * @param callable $httpHandler + * + * @return array { + * A set of auth related metadata, containing the following + * + * @type string $access_token + * @type int $expires_at + * } + */ + private function getImpersonatedAccessToken(string $stsToken, callable $httpHandler = null): array + { + if (!isset($this->serviceAccountImpersonationUrl)) { + throw new InvalidArgumentException( + 'service_account_impersonation_url must be set in JSON credentials.' + ); + } + $request = new Request( + 'POST', + $this->serviceAccountImpersonationUrl, + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $stsToken, + ], + (string) json_encode([ + 'lifetime' => sprintf('%ss', OAuth2::DEFAULT_EXPIRY_SECONDS), + 'scope' => $this->auth->getScope(), + ]), + ); + if (is_null($httpHandler)) { + $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); + } + $response = $httpHandler($request); + $body = json_decode((string) $response->getBody(), true); + return [ + 'access_token' => $body['accessToken'], + 'expires_at' => strtotime($body['expireTime']), + ]; + } /** * @param callable $httpHandler @@ -120,15 +202,21 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr * 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 int $expires_at (impersonated service accounts only) + * @type int $expires_in (identity pool only) + * @type string $issued_token_type (identity pool only) + * @type string $token_type (identity pool only) * } */ public function fetchAuthToken(callable $httpHandler = null) { - return $this->auth->fetchAuthToken($httpHandler); + $stsToken = $this->auth->fetchAuthToken($httpHandler); + + if (isset($this->serviceAccountImpersonationUrl)) { + return $this->getImpersonatedAccessToken($stsToken['access_token'], $httpHandler); + } + + return $stsToken; } public function getCacheKey() @@ -140,4 +228,14 @@ public function getLastReceivedToken() { return $this->auth->getLastReceivedToken(); } + + /** + * Get the quota project used for this API request + * + * @return string|null + */ + public function getQuotaProject() + { + return $this->quotaProject; + } } diff --git a/tests/ApplicationDefaultCredentialsTest.php b/tests/ApplicationDefaultCredentialsTest.php index e3d7a8dcc..b3731f8bd 100644 --- a/tests/ApplicationDefaultCredentialsTest.php +++ b/tests/ApplicationDefaultCredentialsTest.php @@ -780,6 +780,7 @@ public function provideExternalAccountCredentials() return [ ['file_credentials.json', CredentialSource\FileSource::class], ['url_credentials.json', CredentialSource\UrlSource::class], + ['aws_credentials.json', CredentialSource\AwsNativeSource::class], ]; } } diff --git a/tests/CredentialSource/AwsNativeSourceTest.php b/tests/CredentialSource/AwsNativeSourceTest.php new file mode 100644 index 000000000..fc44f2ebd --- /dev/null +++ b/tests/CredentialSource/AwsNativeSourceTest.php @@ -0,0 +1,371 @@ +assertEquals('GET', $request->getMethod()); + $this->assertEquals($this->regionUrl, (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('us-east-2b'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $region = AwsNativeSource::getRegionFromUrl($httpHandler, $this->regionUrl, []); + $this->assertEquals('us-east-2', $region); + } + + /** @runInSeparateProcess */ + public function testGetRegionFromEnv() + { + // Without any environment variables set, getRegionFromEnv should return null + $this->assertNull(AwsNativeSource::getRegionFromEnv()); + + // Requires AWS_REGION or AWS_DEFAULT_REGION to be set + putenv('AWS_REGION=aws-region'); + $this->assertEquals('aws-region', AwsNativeSource::getRegionFromEnv()); + + // Setting the default region does not hvae an effect + putenv('AWS_DEFAULT_REGION=aws-default-region'); + $this->assertEquals('aws-region', AwsNativeSource::getRegionFromEnv()); + + // Unsetting the AWS_REGION uses AWS_DEFAULT_REGION instead + putenv('AWS_REGION='); + $this->assertEquals('aws-default-region', AwsNativeSource::getRegionFromEnv()); + } + + public function testGetRoleName() + { + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals($this->securityCredentialsUrl, (string) $request->getUri()); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('expected-role-name'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $roleName = AwsNativeSource::getRoleName($httpHandler, $this->securityCredentialsUrl, []); + + $this->assertEquals('expected-role-name', $roleName); + } + + public function testGetImdsV2SessionToken() + { + $imdsV2Url = 'http://some-metadata-url/latest/api/token'; + $httpHandler = function (RequestInterface $request) use ($imdsV2Url): ResponseInterface { + $this->assertEquals('PUT', $request->getMethod()); + $this->assertEquals($imdsV2Url, (string) $request->getUri()); + $this->assertEquals('21600', $request->getHeaderLine('X-aws-ec2-metadata-token-ttl-seconds')); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('expected-aws-token'); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $roleName = AwsNativeSource::getImdsV2SessionToken($imdsV2Url, $httpHandler); + + $this->assertEquals('expected-aws-token', $roleName); + } + + public function testGetSigningVarsFromUrl() + { + $httpHandler = function (RequestInterface $request): ResponseInterface { + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + $this->securityCredentialsUrl . '/test-role-name', + (string) $request->getUri() + ); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'expected-access-key-id', + 'SecretAccessKey' => 'expected-secret-access-key', + 'Token' => 'expected-token', + ])); + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + return $response->reveal(); + }; + + $signingVars = AwsNativeSource::getSigningVarsFromUrl( + $httpHandler, + $this->securityCredentialsUrl, + 'test-role-name', + [] + ); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-token', $signingVars[2]); + } + + /** @runInSeparateProcess */ + public function testGetSigningVarsFromEnv() + { + // Without any environment variables set, getSigningVarsFromEnv should return null + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + + $this->assertNull($signingVars); + + // Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set + putenv('AWS_ACCESS_KEY_ID=expected-access-key-id'); + putenv('AWS_SECRET_ACCESS_KEY=expected-secret-access-key'); + + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertNull($signingVars[2]); + + // AWS_SESSION_TOKEN is optional + putenv('AWS_SESSION_TOKEN=expected-session-token'); + + $signingVars = AwsNativeSource::getSigningVarsFromEnv(); + $this->assertEquals('expected-access-key-id', $signingVars[0]); + $this->assertEquals('expected-secret-access-key', $signingVars[1]); + $this->assertEquals('expected-session-token', $signingVars[2]); + } + + public function testGetSignedRequestHeaders() + { + $region = 'us-east-2'; + $host = 'sts.us-east-2.amazonaws.com'; + $accessKeyId = 'expected-access-key-id'; + $secretAccessKey = 'expected-secret-access-key'; + $securityToken = null; + $headers = AwsNativeSource::getSignedRequestHeaders( + $host, + $region, + $accessKeyId, + $secretAccessKey, + $securityToken + ); + + $this->assertArrayHasKey('x-amz-date', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertArrayNotHasKey('x-amz-security-token', $headers); + $this->assertStringStartsWith('AWS4-HMAC-SHA256 ', $headers['Authorization']); + $this->assertStringContainsString( + ' Credential=expected-access-key-id/', + $headers['Authorization'] + ); + $this->assertStringContainsString( + '/sts/aws4_request, SignedHeaders=host;x-amz-date, ', + $headers['Authorization'] + ); + $this->assertStringContainsString( + ', Signature=', + $headers['Authorization'] + ); + + $securityToken = 'extected-security-token'; + $headers = AwsNativeSource::getSignedRequestHeaders( + $region, + $host, + $accessKeyId, + $secretAccessKey, + $securityToken + ); + + $this->assertArrayHasKey('x-amz-date', $headers); + $this->assertArrayHasKey('Authorization', $headers); + $this->assertArrayHasKey('x-amz-security-token', $headers); + $this->assertStringStartsWith('AWS4-HMAC-SHA256 ', $headers['Authorization']); + $this->assertStringContainsString( + ' Credential=expected-access-key-id/', + $headers['Authorization'] + ); + $this->assertStringContainsString( + '/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, ', + $headers['Authorization'] + ); + $this->assertStringContainsString( + ', Signature=', + $headers['Authorization'] + ); + } + + public function testFetchSubjectTokenWithoutSecurityCredentialsUrlOrEnvThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Unable to get credentials from ENV, and no security credentials URL provided' + ); + + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + $httpHandler = function (RequestInterface $request): ResponseInterface { + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + return $awsTokenResponse->reveal(); + }; + $aws->fetchSubjectToken($httpHandler); + } + + /** + * @runInSeparateProcess + */ + public function testFetchSubjectTokenFromEnv() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + ); + + // Set minimum number of environment variables required + putenv('AWS_ACCESS_KEY_ID=expected-access-key-id'); + putenv('AWS_SECRET_ACCESS_KEY=expected-secret-access-key'); + + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: return $awsTokenResponse->reveal(); + case 2: return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } + + public function testFetchSubjectTokenFromUrl() + { + $aws = new AwsNativeSource( + $this->audience, + $this->regionUrl, + $this->regionalCredVerificationUrl, + $this->securityCredentialsUrl, + $this->imdsv2SessionTokenUrl, + ); + + // Mock response from AWS Metadata Server + $awsTokenBody = $this->prophesize(StreamInterface::class); + $awsTokenBody->__toString()->willReturn('aws-token'); + $awsTokenResponse = $this->prophesize(ResponseInterface::class); + $awsTokenResponse->getBody()->willReturn($awsTokenBody->reveal()); + + // Mock response from Role Name request + $roleBody = $this->prophesize(StreamInterface::class); + $roleBody->__toString()->willReturn('test-role-name'); + $roleResponse = $this->prophesize(ResponseInterface::class); + $roleResponse->getBody()->willReturn($roleBody->reveal()); + + // Mock response from Security Credentials URL + $securityCredentialsBody = $this->prophesize(StreamInterface::class); + $securityCredentialsBody->__toString()->willReturn(json_encode([ + 'AccessKeyId' => 'test-access-key-id', + 'SecretAccessKey' => 'test-secret-access-key', + 'Token' => 'test-token', + ])); + $securityCredentialsResponse = $this->prophesize(ResponseInterface::class); + $securityCredentialsResponse->getBody()->willReturn($securityCredentialsBody->reveal()); + + // Mock response from Region URL + $regionBody = $this->prophesize(StreamInterface::class); + $regionBody->__toString()->willReturn('us-east-2b'); + $regionResponse = $this->prophesize(ResponseInterface::class); + $regionResponse->getBody()->willReturn($regionBody->reveal()); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use ( + $awsTokenResponse, + $roleResponse, + $securityCredentialsResponse, + $regionResponse, + &$requestCount + ): ResponseInterface { + $requestCount++; + switch ($requestCount) { + case 1: return $awsTokenResponse->reveal(); + case 2: return $roleResponse->reveal(); + case 3: return $securityCredentialsResponse->reveal(); + case 4: return $regionResponse->reveal(); + } + throw new \Exception('Unexpected request'); + }; + + $subjectToken = $aws->fetchSubjectToken($httpHandler); + $unserializedToken = json_decode(urldecode($subjectToken), true); + $this->assertArrayHasKey('headers', $unserializedToken); + $this->assertArrayHasKey('method', $unserializedToken); + $this->assertArrayHasKey('url', $unserializedToken); + } +} diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php index f5dcc30f7..39fe46045 100644 --- a/tests/Credentials/ExternalAccountCredentialsTest.php +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -18,11 +18,16 @@ namespace Google\Auth\Tests\Credentials; use Google\Auth\Credentials\ExternalAccountCredentials; +use Google\Auth\CredentialSource\AwsNativeSource; use Google\Auth\CredentialSource\FileSource; use Google\Auth\CredentialSource\UrlSource; use Google\Auth\OAuth2; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; /** * @group credentials @@ -30,11 +35,16 @@ */ class ExternalAccountCredentialsTest extends TestCase { + use ProphecyTrait; + /** * @dataProvider provideCredentialSourceFromCredentials */ - public function testCredentialSourceFromCredentials(array $credentialSource, string $expectedSourceClass) - { + public function testCredentialSourceFromCredentials( + array $credentialSource, + string $expectedSourceClass, + array $expectedProperties = [] + ) { $jsonCreds = [ 'type' => 'external_account', 'token_url' => '', @@ -56,18 +66,41 @@ public function testCredentialSourceFromCredentials(array $credentialSource, str $subjectTokenFetcher = $oauthProp->getValue($oauth); $this->assertInstanceOf($expectedSourceClass, $subjectTokenFetcher); + + $sourceReflection = new \ReflectionClass($subjectTokenFetcher); + foreach ($expectedProperties as $propName => $expectedPropValue) { + $sourceProp = $sourceReflection->getProperty($propName); + $sourceProp->setAccessible(true); + $this->assertEquals($expectedPropValue, $sourceProp->getValue($subjectTokenFetcher)); + } } public function provideCredentialSourceFromCredentials() { return [ [ - ['file' => 'path/to/credsfile.json'], - FileSource::class + [ + 'environment_id' => 'aws1', + 'regional_cred_verification_url' => 'abc', + 'region_url' => 'def', + 'url' => 'ghi', + 'imdsv2_session_token_url' => 'jkl' + ], + AwsNativeSource::class, + [ + 'regionalCredVerificationUrl' => 'abc', + 'regionUrl' => 'def', + 'securityCredentialsUrl' => 'ghi', + 'imdsv2SessionTokenUrl' => 'jkl', + ], ], [ ['file' => 'path/to/credsfile.json', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token']], - FileSource::class + FileSource::class, + [ + 'format' => 'json', + 'subjectTokenFieldName' => 'token', + ] ], [ ['url' => 'https://test.com'], @@ -78,8 +111,20 @@ public function provideCredentialSourceFromCredentials() UrlSource::class ], [ - ['url' => 'https://test.com', 'format' => ['type' => 'json', 'subject_token_field_name' => 'token', 'headers' => []]], - UrlSource::class + [ + 'url' => 'https://test.com', + 'format' => [ + 'type' => 'json', + 'subject_token_field_name' => 'token', + ], + 'headers' => ['foo' => 'bar'], + ], + UrlSource::class, + [ + 'format' => 'json', + 'subjectTokenFieldName' => 'token', + 'headers' => ['foo' => 'bar'], + ] ], ]; } @@ -126,6 +171,170 @@ public function provideInvalidCredentialsJson() ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => '', 'credential_source' => []], 'Unable to determine credential source from json key' ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => '', 'credential_source' => [ + 'environment_id' => 'aws2', + ]], + 'aws version "2" is not supported in the current build.' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => '', 'credential_source' => [ + 'environment_id' => 'aws1', + ]], + 'The regional_cred_verification_url field is required for aws1 credential source.' + ], + [ + ['type' => 'external_account', 'token_url' => '', 'audience' => '', 'subject_token_type' => '', 'credential_source' => [ + 'environment_id' => 'aws1', + 'region_url' => '', + ]], + 'The regional_cred_verification_url field is required for aws1 credential source.' + ], + ]; + } + + public function testFetchAuthTokenFileCredentials() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'type' => 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['file' => $tmpFile], + ]; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $httpHandler = function (RequestInterface $request) { + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + + $responseBody = $this->prophesize(StreamInterface::class); + $responseBody->__toString()->willReturn(json_encode(['access_token' => 'def', 'expires_in' => 1000])); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($responseBody->reveal()); + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + } + + public function testFetchAuthTokenUrlCredentials() + { + $jsonCreds = [ + 'type' => 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['url' => 'sts-url.com'], ]; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $requestCount = 0; + $httpHandler = function (RequestInterface $request) use (&$requestCount) { + switch (++$requestCount) { + case 1: + $this->assertEquals('sts-url.com', (string) $request->getUri()); + $responseBody = 'abc'; + break; + + case 2: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $responseBody = '{"access_token": "def"}'; + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 2) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + } + + public function testFetchAuthTokenWithImpersonation() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tmpFile, 'abc'); + + $jsonCreds = [ + 'type' => 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['file' => $tmpFile], + 'service_account_impersonation_url' => 'service-account-impersonation-url.com', + ]; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + + $requestCount = 0; + $expiry = '2023-10-05T18:00:01Z'; + $httpHandler = function (RequestInterface $request) use (&$requestCount, $expiry) { + switch (++$requestCount) { + case 1: + $this->assertEquals('token-url.com', (string) $request->getUri()); + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + $responseBody = '{"access_token": "def"}'; + break; + case 2: + $this->assertEquals('service-account-impersonation-url.com', (string) $request->getUri()); + $responseBody = json_encode(['accessToken' => 'def', 'expireTime' => $expiry]); + break; + } + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn($responseBody); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + if ($requestCount === 1) { + $response->hasHeader('Content-Type')->willReturn(false); + } + + return $response->reveal(); + }; + + $authToken = $creds->fetchAuthToken($httpHandler); + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + $this->assertEquals(strtotime($expiry), $authToken['expires_at']); + } + + public function testGetQuotaProject() + { + $jsonCreds = [ + 'type' => 'external_account', + 'token_url' => 'token-url.com', + 'audience' => '', + 'subject_token_type' => '', + 'credential_source' => ['url' => 'sts-url.com'], + 'quota_project_id' => 'test_quota_project', + ]; + + $creds = new ExternalAccountCredentials('a-scope', $jsonCreds); + $this->assertEquals('test_quota_project', $creds->getQuotaProject()); } } diff --git a/tests/fixtures6/aws_credentials.json b/tests/fixtures6/aws_credentials.json new file mode 100644 index 000000000..db8269146 --- /dev/null +++ b/tests/fixtures6/aws_credentials.json @@ -0,0 +1,13 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/byoid-pool-php/providers/PROJECT_ID", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "environment_id": "aws1", + "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone", + "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials", + "regional_cred_verification_url": "https://sts.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/byoid-test@cicpclientproj.iam.gserviceaccount.com:generateAccessToken" + } From 22209fddd0c06f3f8e3cb4aade0b352aa00f9888 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:39:00 -0700 Subject: [PATCH 05/14] chore(main): release 1.31.0 (#484) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7db97a6e..a4095b46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.31.0](https://github.com/googleapis/google-auth-library-php/compare/v1.30.0...v1.31.0) (2023-10-05) + + +### Features + +* Add AWS credential source ([#474](https://github.com/googleapis/google-auth-library-php/issues/474)) ([e5bc897](https://github.com/googleapis/google-auth-library-php/commit/e5bc8979bf87159d9acab1ca8cb7cd7af008b2a6)) + ## [1.30.0](https://github.com/googleapis/google-auth-library-php/compare/v1.29.1...v1.30.0) (2023-09-07) From 6d426b5cb9462845d2c2d7d506318c9bee613528 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 10 Oct 2023 10:52:44 -0700 Subject: [PATCH 06/14] feat: respect cache control for access token certs (#479) --- src/AccessToken.php | 31 +++++++++++++------ tests/AccessTokenTest.php | 64 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/AccessToken.php b/src/AccessToken.php index e1f92ee7e..0afc4ca1e 100644 --- a/src/AccessToken.php +++ b/src/AccessToken.php @@ -311,11 +311,9 @@ private function getCerts($location, $cacheKey, array $options = []) $cacheItem = $this->cache->getItem($cacheKey); $certs = $cacheItem ? $cacheItem->get() : null; - $gotNewCerts = false; + $expireTime = null; if (!$certs) { - $certs = $this->retrieveCertsFromLocation($location, $options); - - $gotNewCerts = true; + list($certs, $expireTime) = $this->retrieveCertsFromLocation($location, $options); } if (!isset($certs['keys'])) { @@ -331,8 +329,8 @@ private function getCerts($location, $cacheKey, array $options = []) // Push caching off until after verifying certs are in a valid format. // Don't want to cache bad data. - if ($gotNewCerts) { - $cacheItem->expiresAt(new DateTime('+1 hour')); + if ($expireTime) { + $cacheItem->expiresAt(new DateTime($expireTime)); $cacheItem->set($certs); $this->cache->save($cacheItem); } @@ -345,13 +343,14 @@ private function getCerts($location, $cacheKey, array $options = []) * * @param string $url location * @param array $options [optional] Configuration options. - * @return array certificates + * @return array{array, string} * @throws InvalidArgumentException If certs could not be retrieved from a local file. * @throws RuntimeException If certs could not be retrieved from a remote location. */ private function retrieveCertsFromLocation($url, array $options = []) { // If we're retrieving a local file, just grab it. + $expireTime = '+1 hour'; if (strpos($url, 'http') !== 0) { if (!file_exists($url)) { throw new InvalidArgumentException(sprintf( @@ -360,14 +359,28 @@ private function retrieveCertsFromLocation($url, array $options = []) )); } - return json_decode((string) file_get_contents($url), true); + return [ + json_decode((string) file_get_contents($url), true), + $expireTime + ]; } $httpHandler = $this->httpHandler; $response = $httpHandler(new Request('GET', $url), $options); if ($response->getStatusCode() == 200) { - return json_decode((string) $response->getBody(), true); + if ($cacheControl = $response->getHeaderLine('Cache-Control')) { + array_map(function ($value) use (&$expireTime) { + list($key, $value) = explode('=', $value) + [null, null]; + if (trim($key) == 'max-age') { + $expireTime = '+' . $value . ' seconds'; + } + }, explode(',', $cacheControl)); + } + return [ + json_decode((string) $response->getBody(), true), + $expireTime + ]; } throw new RuntimeException(sprintf( diff --git a/tests/AccessTokenTest.php b/tests/AccessTokenTest.php index f2a6f3c81..de51474b2 100644 --- a/tests/AccessTokenTest.php +++ b/tests/AccessTokenTest.php @@ -264,7 +264,10 @@ public function testEsVerifyEndToEnd() $this->assertEquals('https://cloud.google.com/iap', $payload['iss']); } - public function testGetCertsForIap() + /** + * @dataProvider provideCertsFromUrl + */ + public function testGetCertsFromUrl($certUrl) { $token = new AccessToken(); $reflector = new \ReflectionObject($token); @@ -272,14 +275,22 @@ public function testGetCertsForIap() $cacheKeyMethod->setAccessible(true); $getCertsMethod = $reflector->getMethod('getCerts'); $getCertsMethod->setAccessible(true); - $cacheKey = $cacheKeyMethod->invoke($token, AccessToken::IAP_CERT_URL); + $cacheKey = $cacheKeyMethod->invoke($token, $certUrl); $certs = $getCertsMethod->invoke( $token, - AccessToken::IAP_CERT_URL, + $certUrl, $cacheKey ); $this->assertTrue(is_array($certs)); - $this->assertEquals(5, count($certs)); + $this->assertGreaterThanOrEqual(2, count($certs)); + } + + public function provideCertsFromUrl() + { + return [ + [AccessToken::IAP_CERT_URL], + [AccessToken::FEDERATED_SIGNON_CERT_URL], + ]; } public function testRetrieveCertsFromLocationLocalFile() @@ -398,6 +409,51 @@ public function testRetrieveCertsFromLocationLocalFileInvalidFileData() ]); } + public function testRetrieveCertsFromLocationRespectsCacheControl() + { + $certsLocation = __DIR__ . '/fixtures/federated-certs.json'; + $certsJson = file_get_contents($certsLocation); + $certsData = json_decode($certsJson, true); + + $httpHandler = function (RequestInterface $request) use ($certsJson) { + return new Response(200, [ + 'cache-control' => 'public, max-age=1000', + ], $certsJson); + }; + + $phpunit = $this; + + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->get() + ->shouldBeCalledTimes(1) + ->willReturn(null); + $item->set($certsData) + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + // Assert date-time is set with difference of 1000 (the max-age in the Cache-Control header) + $item->expiresAt(Argument::type('\DateTime')) + ->shouldBeCalledTimes(1) + ->will(function ($value) use ($phpunit) { + $phpunit->assertEqualsWithDelta(1000, $value[0]->getTimestamp() - time(), 1); + return $this; + }); + + $this->cache->getItem('google_auth_certs_cache|federated_signon_certs_v3') + ->shouldBeCalledTimes(1) + ->willReturn($item->reveal()); + + $this->cache->save(Argument::type('Psr\Cache\CacheItemInterface')) + ->shouldBeCalledTimes(1); + + $token = new AccessTokenStub( + $httpHandler, + $this->cache->reveal() + ); + + $token->verify($this->token); + } + public function testRetrieveCertsFromLocationRemote() { $certsLocation = __DIR__ . '/fixtures/federated-certs.json'; From b88250090d834add55e4ad31f84190d51f617fca Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 11:38:15 -0700 Subject: [PATCH 07/14] chore(main): release 1.32.0 (#488) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4095b46e..951130aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.32.0](https://github.com/googleapis/google-auth-library-php/compare/v1.31.0...v1.32.0) (2023-10-10) + + +### Features + +* Respect cache control for access token certs ([#479](https://github.com/googleapis/google-auth-library-php/issues/479)) ([6d426b5](https://github.com/googleapis/google-auth-library-php/commit/6d426b5cb9462845d2c2d7d506318c9bee613528)) + ## [1.31.0](https://github.com/googleapis/google-auth-library-php/compare/v1.30.0...v1.31.0) (2023-10-05) From 0042b522ebbcffc6d6623e322d162d963eada3b5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 17 Oct 2023 14:06:26 -0700 Subject: [PATCH 08/14] fix: allowed_algs not properly set for string value (#489) --- src/OAuth2.php | 2 +- tests/OAuth2Test.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 3db54c769..2e5adcdcf 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -1723,7 +1723,7 @@ private function getFirebaseJwtKeys($publicKey, $allowedAlgs) $allowedAlg = null; if (is_string($allowedAlgs)) { - $allowedAlg = $allowedAlg; + $allowedAlg = $allowedAlgs; } elseif (is_array($allowedAlgs)) { if (count($allowedAlgs) > 1) { throw new \InvalidArgumentException( diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index 8de3f35b9..e00ab647f 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -1250,8 +1250,14 @@ public function testShouldReturnAValidIdToken() $alg = 'RS256'; $jwtIdToken = JWT::encode($origIdToken, $privateKey, $alg); $o->setIdToken($jwtIdToken); + + // Test with array alg $roundTrip = $o->verifyIdToken($publicKey, [$alg]); $this->assertEquals($origIdToken['aud'], $roundTrip->aud); + + // Test with string alg + $roundTrip2 = $o->verifyIdToken($publicKey, $alg); + $this->assertEquals($origIdToken['aud'], $roundTrip2->aud); } } From 999e9ce8b9d17914f04e1718271a0a46da4de2f3 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 14:13:22 -0700 Subject: [PATCH 09/14] chore(main): release 1.32.1 (#490) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 951130aae..4ec078dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.32.1](https://github.com/googleapis/google-auth-library-php/compare/v1.32.0...v1.32.1) (2023-10-17) + + +### Bug Fixes + +* Allowed_algs not properly set for string value ([#489](https://github.com/googleapis/google-auth-library-php/issues/489)) ([0042b52](https://github.com/googleapis/google-auth-library-php/commit/0042b522ebbcffc6d6623e322d162d963eada3b5)) + ## [1.32.0](https://github.com/googleapis/google-auth-library-php/compare/v1.31.0...v1.32.0) (2023-10-10) From 3d68b6d5161900abb873f252f4106f9a6aa2a230 Mon Sep 17 00:00:00 2001 From: Yash Sahu <54198301+yash30201@users.noreply.github.com> Date: Wed, 15 Nov 2023 08:47:17 +0000 Subject: [PATCH 10/14] chore: refactor AuthTokenMiddleware logic (#492) --- src/Middleware/AuthTokenMiddleware.php | 47 ++++---- tests/FetchAuthTokenTest.php | 17 ++- tests/Middleware/AuthTokenMiddlewareTest.php | 109 +++++++++++++------ 3 files changed, 119 insertions(+), 54 deletions(-) diff --git a/src/Middleware/AuthTokenMiddleware.php b/src/Middleware/AuthTokenMiddleware.php index 1e2f7fb6d..b10cf9bff 100644 --- a/src/Middleware/AuthTokenMiddleware.php +++ b/src/Middleware/AuthTokenMiddleware.php @@ -17,8 +17,11 @@ namespace Google\Auth\Middleware; +use Google\Auth\FetchAuthTokenCache; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\GetQuotaProjectInterface; +use Google\Auth\UpdateMetadataInterface; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\RequestInterface; /** @@ -40,6 +43,9 @@ class AuthTokenMiddleware private $httpHandler; /** + * It must be an implementation of FetchAuthTokenInterface. + * It may also implement UpdateMetadataInterface allowing direct + * retrieval of auth related headers * @var FetchAuthTokenInterface */ private $fetcher; @@ -99,7 +105,7 @@ public function __invoke(callable $handler) return $handler($request, $options); } - $request = $request->withHeader('authorization', 'Bearer ' . $this->fetchToken()); + $request = $this->addAuthHeaders($request); if ($quotaProject = $this->getQuotaProject()) { $request = $request->withHeader( @@ -113,32 +119,33 @@ public function __invoke(callable $handler) } /** - * Call fetcher to fetch the token. + * Adds auth related headers to the request. * - * @return string|null + * @param RequestInterface $request + * @return RequestInterface */ - private function fetchToken() + private function addAuthHeaders(RequestInterface $request) { - $auth_tokens = (array) $this->fetcher->fetchAuthToken($this->httpHandler); - - if (array_key_exists('access_token', $auth_tokens)) { - // notify the callback if applicable - if ($this->tokenCallback) { - call_user_func( - $this->tokenCallback, - $this->fetcher->getCacheKey(), - $auth_tokens['access_token'] - ); - } - - return $auth_tokens['access_token']; + if (!$this->fetcher instanceof UpdateMetadataInterface || + ($this->fetcher instanceof FetchAuthTokenCache && + !$this->fetcher->getFetcher() instanceof UpdateMetadataInterface) + ) { + $token = $this->fetcher->fetchAuthToken(); + $request = $request->withHeader( + 'authorization', 'Bearer ' . ($token['access_token'] ?? $token['id_token']) + ); + } else { + $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); + $request = Utils::modifyRequest($request, ['set_headers' => $headers]); } - if (array_key_exists('id_token', $auth_tokens)) { - return $auth_tokens['id_token']; + if ($this->tokenCallback && ($token = $this->fetcher->getLastReceivedToken())) { + if (array_key_exists('access_token', $token)) { + call_user_func($this->tokenCallback, $this->fetcher->getCacheKey(), $token['access_token']); + } } - return null; + return $request; } /** diff --git a/tests/FetchAuthTokenTest.php b/tests/FetchAuthTokenTest.php index 5b7badbe0..6fe7df242 100644 --- a/tests/FetchAuthTokenTest.php +++ b/tests/FetchAuthTokenTest.php @@ -25,6 +25,7 @@ use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\OAuth2; +use Google\Auth\UpdateMetadataInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -53,10 +54,20 @@ class_implements($fetcherClass) )) { $mockFetcher->getQuotaProject()->shouldBeCalledTimes(1); } - $mockFetcher->fetchAuthToken(Argument::any()) - ->shouldBeCalledTimes(1) - ->will($httpHandler); + + if (is_a($fetcherClass, UpdateMetadataInterface::class, true)) { + $mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1)->will(function () use (&$httpHandlerCalled) { + $httpHandlerCalled = true; + return ['authorization' => ['Bearer xyz']]; + }); + } else { + $mockFetcher->fetchAuthToken(Argument::any()) + ->shouldBeCalledTimes(1) + ->will($httpHandler); + } $mockFetcher->getCacheKey()->willReturn(''); + $mockFetcher->getLastReceivedToken()->willReturn(['access_token' => 'xyz']); $tokenCallbackCalled = false; $tokenCallback = function ($cacheKey, $accessToken) use (&$tokenCallbackCalled) { diff --git a/tests/Middleware/AuthTokenMiddlewareTest.php b/tests/Middleware/AuthTokenMiddlewareTest.php index 06c6ee485..cf0eb4182 100644 --- a/tests/Middleware/AuthTokenMiddlewareTest.php +++ b/tests/Middleware/AuthTokenMiddlewareTest.php @@ -20,7 +20,9 @@ use Google\Auth\FetchAuthTokenCache; use Google\Auth\Middleware\AuthTokenMiddleware; use Google\Auth\Tests\BaseTest; +use Google\Auth\UpdateMetadataInterface; use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -64,11 +66,7 @@ public function testAddsTheTokenAsAnAuthorizationHeader() ->shouldBeCalledTimes(1) ->willReturn($this->mockRequest->reveal()); - // Run the test. - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($this->mockFetcher->reveal()); } public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() @@ -80,11 +78,7 @@ public function testDoesNotAddAnAuthorizationHeaderOnNoAccessToken() $this->mockRequest->withHeader('authorization', 'Bearer ') ->willReturn($this->mockRequest->reveal()); - // Run the test. - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($this->mockFetcher->reveal()); } public function testUsesIdTokenWhenAccessTokenDoesNotExist() @@ -96,12 +90,10 @@ public function testUsesIdTokenWhenAccessTokenDoesNotExist() ->willReturn($authResult); $this->mockRequest->withHeader('authorization', 'Bearer ' . $token) ->shouldBeCalledTimes(1) - ->willReturn($this->mockRequest); + ->willReturn($this->mockRequest->reveal()); + + $this->runTestCase($this->mockFetcher->reveal()); - $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); } public function testUsesCachedAccessToken() @@ -133,10 +125,7 @@ public function testUsesCachedAccessToken() null, $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testUsesCachedIdToken() @@ -168,10 +157,7 @@ public function testUsesCachedIdToken() null, $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testGetsCachedAuthTokenUsingCacheOptions() @@ -204,10 +190,7 @@ public function testGetsCachedAuthTokenUsingCacheOptions() ['prefix' => $prefix], $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } public function testShouldSaveValueInCacheWithSpecifiedPrefix() @@ -248,10 +231,7 @@ public function testShouldSaveValueInCacheWithSpecifiedPrefix() ['prefix' => $prefix, 'lifetime' => $lifetime], $this->mockCache->reveal() ); - $middleware = new AuthTokenMiddleware($cachedFetcher); - $mock = new MockHandler([new Response(200)]); - $callable = $middleware($mock); - $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + $this->runTestCase($cachedFetcher); } /** @@ -282,6 +262,8 @@ public function testShouldNotifyTokenCallback(callable $tokenCallback) $this->mockFetcher->fetchAuthToken(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn($cachedValue); + $this->mockFetcher->getLastReceivedToken() + ->willReturn($cachedValue); $this->mockRequest->withHeader(Argument::any(), Argument::any()) ->willReturn($this->mockRequest->reveal()); @@ -306,6 +288,71 @@ public function testShouldNotifyTokenCallback(callable $tokenCallback) $this->assertTrue(MiddlewareCallback::$called); } + public function testAddAuthHeadersFromUpdateMetadata() + { + $authResult = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + ]; + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authResult); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $request = new Request('GET', 'http://foo.com'); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authResult, &$mockHandlerCalled) { + $this->assertEquals($authResult['authorization'], $request->getHeaderLine('authorization')); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + public function testOverlappingAddAuthHeadersFromUpdateMetadata() + { + $authHeaders = [ + 'authorization' => 'Bearer 1/abcdef1234567890', + 'x-goog-api-client' => 'extra-value' + ]; + + $request = new Request('GET', 'http://foo.com'); + + $this->mockFetcher->willImplement(UpdateMetadataInterface::class); + $this->mockFetcher->updateMetadata(Argument::cetera()) + ->shouldBeCalledTimes(1) + ->willReturn($authHeaders); + $this->mockFetcher->getLastReceivedToken() + ->willReturn(['access_token' => '1/abcdef1234567890']); + + $middleware = new AuthTokenMiddleware($this->mockFetcher->reveal()); + + $mockHandlerCalled = false; + $mock = new MockHandler([function ($request, $options) use ($authHeaders, &$mockHandlerCalled) { + $this->assertEquals($authHeaders['authorization'], $request->getHeaderLine('authorization')); + $this->assertArrayHasKey('x-goog-api-client', $request->getHeaders()); + $mockHandlerCalled = true; + return new Response(200); + }]); + $callable = $middleware($mock); + $callable($request, ['auth' => 'google_auth']); + $this->assertTrue($mockHandlerCalled); + } + + private function runTestCase($fetcher) + { + $middleware = new AuthTokenMiddleware($fetcher); + $mock = new MockHandler([new Response(200)]); + $callable = $middleware($mock); + $callable($this->mockRequest->reveal(), ['auth' => 'google_auth']); + } + public function provideShouldNotifyTokenCallback() { MiddlewareCallback::$phpunit = $this; From 35781ed573aa9d831d38452eefbac790559dfb97 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 28 Nov 2023 09:47:05 -0600 Subject: [PATCH 11/14] feat: add and implement universe domain interface (#477) --- src/Credentials/ServiceAccountCredentials.php | 19 ++++++++++ src/CredentialsLoader.php | 12 +++++++ src/FetchAuthTokenCache.php | 15 ++++++++ src/GetUniverseDomainInterface.php | 35 ++++++++++++++++++ tests/ApplicationDefaultCredentialsTest.php | 22 ++++++++++++ tests/Credentials/GCECredentialsTest.php | 8 +++++ tests/FetchAuthTokenCacheTest.php | 36 +++++++++++++++++++ tests/fixtures/private.json | 3 +- tests/fixtures2/private.json | 3 +- 9 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/GetUniverseDomainInterface.php diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 76aa0fc99..086417c07 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -99,6 +99,11 @@ class ServiceAccountCredentials extends CredentialsLoader implements */ private $jwtAccessCredentials; + /** + * @var string|null + */ + private ?string $universeDomain; + /** * Create a new ServiceAccountCredentials. * @@ -159,6 +164,7 @@ public function __construct( ]); $this->projectId = $jsonKey['project_id'] ?? null; + $this->universeDomain = $jsonKey['universe_domain'] ?? null; } /** @@ -328,6 +334,19 @@ public function getQuotaProject() return $this->quotaProject; } + /** + * Get the universe domain configured in the JSON credential. + * + * @return string + */ + public function getUniverseDomain(): string + { + if (null === $this->universeDomain) { + return self::DEFAULT_UNIVERSE_DOMAIN; + } + return $this->universeDomain; + } + /** * @return bool */ diff --git a/src/CredentialsLoader.php b/src/CredentialsLoader.php index 9e28701ed..746b957a9 100644 --- a/src/CredentialsLoader.php +++ b/src/CredentialsLoader.php @@ -30,6 +30,7 @@ * credentials files on the file system. */ abstract class CredentialsLoader implements + GetUniverseDomainInterface, FetchAuthTokenInterface, UpdateMetadataInterface { @@ -273,4 +274,15 @@ private static function loadDefaultClientCertSourceFile() } return $clientCertSourceJson; } + + /** + * Get the universe domain from the credential. Defaults to "googleapis.com" + * for all credential types which do not support universe domain. + * + * @return string + */ + public function getUniverseDomain(): string + { + return self::DEFAULT_UNIVERSE_DOMAIN; + } } diff --git a/src/FetchAuthTokenCache.php b/src/FetchAuthTokenCache.php index 47174a1b7..cac1984ab 100644 --- a/src/FetchAuthTokenCache.php +++ b/src/FetchAuthTokenCache.php @@ -26,6 +26,7 @@ class FetchAuthTokenCache implements FetchAuthTokenInterface, GetQuotaProjectInterface, + GetUniverseDomainInterface, SignBlobInterface, ProjectIdProviderInterface, UpdateMetadataInterface @@ -191,6 +192,20 @@ public function getProjectId(callable $httpHandler = null) return $this->fetcher->getProjectId($httpHandler); } + /* + * Get the Universe Domain from the fetcher. + * + * @return string + */ + public function getUniverseDomain(): string + { + if ($this->fetcher instanceof GetUniverseDomainInterface) { + return $this->fetcher->getUniverseDomain(); + } + + return GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; + } + /** * Updates metadata with the authorization token. * diff --git a/src/GetUniverseDomainInterface.php b/src/GetUniverseDomainInterface.php new file mode 100644 index 000000000..1656ddc2e --- /dev/null +++ b/src/GetUniverseDomainInterface.php @@ -0,0 +1,35 @@ +assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain()); + + // Test universe domain in "service_account" keyfile + $keyFile = __DIR__ . '/fixtures/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals('example-universe.com', $creds->getUniverseDomain()); + + // Test universe domain in "authenticated_user" keyfile is not read. + $keyFile = __DIR__ . '/fixtures2/private.json'; + putenv(ServiceAccountCredentials::ENV_VAR . '=' . $keyFile); + $creds2 = ApplicationDefaultCredentials::getCredentials(); + $this->assertEquals(CredentialsLoader::DEFAULT_UNIVERSE_DOMAIN, $creds2->getUniverseDomain()); + } } diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 0d36e6771..9369e40ac 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -512,4 +512,12 @@ public function testGetClientNameWithServiceAccountIdentity() $creds = new GCECredentials(null, null, null, null, 'foo'); $this->assertEquals($expected, $creds->getClientName($httpHandler)); } + + public function testGetUniverseDomain() + { + $creds = new GCECredentials(); + + // Universe domain should always be the default + $this->assertEquals(GCECredentials::DEFAULT_UNIVERSE_DOMAIN, $creds->getUniverseDomain()); + } } diff --git a/tests/FetchAuthTokenCacheTest.php b/tests/FetchAuthTokenCacheTest.php index f59c9295a..21a68e702 100644 --- a/tests/FetchAuthTokenCacheTest.php +++ b/tests/FetchAuthTokenCacheTest.php @@ -21,6 +21,7 @@ use Google\Auth\Credentials\ServiceAccountCredentials; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenCache; +use Google\Auth\GetUniverseDomainInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use RuntimeException; @@ -603,6 +604,41 @@ public function testGetProjectIdInvalidFetcher() $fetcher->getProjectId(); } + public function testGetUniverseDomain() + { + $universeDomain = 'foobar'; + + $mockFetcher = $this->prophesize('Google\Auth\GetUniverseDomainInterface'); + $mockFetcher->willImplement('Google\Auth\FetchAuthTokenInterface'); + $mockFetcher->getUniverseDomain() + ->shouldBeCalled() + ->willReturn($universeDomain); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals($universeDomain, $fetcher->getUniverseDomain()); + } + + public function testGetUniverseDomainInvalidFetcher() + { + $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface'); + + $fetcher = new FetchAuthTokenCache( + $mockFetcher->reveal(), + [], + $this->mockCache->reveal() + ); + + $this->assertEquals( + GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN, + $fetcher->getUniverseDomain() + ); + } + public function testGetFetcher() { $mockFetcher = $this->prophesize('Google\Auth\FetchAuthTokenInterface') diff --git a/tests/fixtures/private.json b/tests/fixtures/private.json index 5d6d1ea64..ef1d49507 100644 --- a/tests/fixtures/private.json +++ b/tests/fixtures/private.json @@ -4,5 +4,6 @@ "client_email": "hello@youarecool.com", "client_id": "client123", "type": "service_account", - "quota_project_id": "test_quota_project" + "quota_project_id": "test_quota_project", + "universe_domain": "example-universe.com" } diff --git a/tests/fixtures2/private.json b/tests/fixtures2/private.json index 20bb61793..9ae0aae96 100644 --- a/tests/fixtures2/private.json +++ b/tests/fixtures2/private.json @@ -3,5 +3,6 @@ "client_secret": "clientSecret123", "refresh_token": "refreshToken123", "type": "authorized_user", - "quota_project_id": "test_quota_project" + "quota_project_id": "test_quota_project", + "universe_domain": "example-universe.com" } From 90dc9c3ba6aa0a4d7c0c5bb4333504585c3cd82e Mon Sep 17 00:00:00 2001 From: Yash Sahu <54198301+yash30201@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:31:46 +0000 Subject: [PATCH 12/14] chore(docs): info for configuring workload identity federation (#495) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 91f12b2db..87f6f6064 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,18 @@ print_r((string) $response->getBody()); [iap-proxy-header]: https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_proxy-authorization_header +#### External credentials (Workload identity federation) + +Using workload identity federation, your application can access Google Cloud resources from Amazon Web Services (AWS), +Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). + +Traditionally, applications running outside Google Cloud have used service account keys to access Google Cloud +resources. Using identity federation, you can allow your workload to impersonate a service account. This lets you access +Google Cloud resources directly, eliminating the maintenance and security burden associated with service account keys. + +Follow the detailed instructions on how to +[Configure Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds). + #### Verifying JWTs If you are [using Google ID tokens to authenticate users][google-id-tokens], use From cee66b650b0f6ec47660d2b3774bf5a48938d845 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 29 Nov 2023 10:59:01 -0600 Subject: [PATCH 13/14] chore(ci): add workflow for running google/cloud tests before releases (#493) --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..db8860b04 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release Pre-Check +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + release-suite: + runs-on: ubuntu-latest + name: Run googleapis/google-cloud-php tests against latest version + if: github.event.pull_request.user.login == 'release-please[bot]' + steps: + - uses: actions/checkout@v4 + - name: Clone googleapis/google-cloud-php + uses: actions/checkout@master + with: + repository: googleapis/google-cloud-php + path: google-cloud-php + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: grpc + - name: Configure google/auth to dev-main + run: | + cd google-cloud-php + composer install -q -d dev + dev/google-cloud update-deps google/auth 'dev-main as 1.200.0' --add=dev + - name: Run google/cloud package tests + run: | + cd google-cloud-php + bash .github/run-package-tests.sh + From 682dc6c30bb509953c9e43bb0960d901582da00b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 30 Nov 2023 07:49:27 -0800 Subject: [PATCH 14/14] chore(main): release 1.33.0 (#497) --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec078dcf..4bc891fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.33.0](https://github.com/googleapis/google-auth-library-php/compare/v1.32.1...v1.33.0) (2023-11-29) + + +### Features + +* Add and implement universe domain interface ([#477](https://github.com/googleapis/google-auth-library-php/issues/477)) ([35781ed](https://github.com/googleapis/google-auth-library-php/commit/35781ed573aa9d831d38452eefbac790559dfb97)) + +### Miscellaneous + +* Refactor `AuthTokenMiddleware` ([#492](https://github.com/googleapis/google-auth-library-php/pull/492)) + ## [1.32.1](https://github.com/googleapis/google-auth-library-php/compare/v1.32.0...v1.32.1) (2023-10-17)