From e5bc8979bf87159d9acab1ca8cb7cd7af008b2a6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 5 Oct 2023 13:08:26 -0700 Subject: [PATCH] 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" + }