diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 2b704aa4a..7ef8f7045 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -110,6 +110,8 @@ class GCECredentials extends CredentialsLoader implements */ private const GKE_PRODUCT_NAME_FILE = '/sys/class/dmi/id/product_name'; + private const CRED_TYPE = 'mds'; + /** * Note: the explicit `timeout` and `tries` below is a workaround. The underlying * issue is that resolving an unknown host on some networks will take @@ -359,7 +361,10 @@ public static function onGce(callable $httpHandler = null) new Request( 'GET', $checkUri, - [self::FLAVOR_HEADER => 'Google'] + [ + self::FLAVOR_HEADER => 'Google', + self::$metricMetadataKey => self::getMetricsHeader('', 'mds') + ] ), ['timeout' => self::COMPUTE_PING_CONNECTION_TIMEOUT_S] ); @@ -421,7 +426,11 @@ public function fetchAuthToken(callable $httpHandler = null) return []; // return an empty array with no access token } - $response = $this->getFromMetadata($httpHandler, $this->tokenUri); + $response = $this->getFromMetadata( + $httpHandler, + $this->tokenUri, + $this->applyTokenEndpointMetrics([], $this->targetAudience ? 'it' : 'at') + ); if ($this->targetAudience) { return $this->lastReceivedToken = ['id_token' => $response]; @@ -579,15 +588,18 @@ public function getUniverseDomain(callable $httpHandler = null): string * * @param callable $httpHandler An HTTP Handler to deliver PSR7 requests. * @param string $uri The metadata URI. + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. + * * @return string */ - private function getFromMetadata(callable $httpHandler, $uri) + private function getFromMetadata(callable $httpHandler, $uri, array $headers = []) { $resp = $httpHandler( new Request( 'GET', $uri, - [self::FLAVOR_HEADER => 'Google'] + [self::FLAVOR_HEADER => 'Google'] + $headers ) ); @@ -619,4 +631,9 @@ public function setIsOnGce($isOnGce) // Set isOnGce $this->isOnGce = $isOnGce; } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 1b4e46eaf..791fe985a 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,6 +26,8 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements { use IamSignerTrait; + private const CRED_TYPE = 'imp'; + /** * @var string */ @@ -121,7 +123,11 @@ public function getClientName(callable $unusedHttpHandler = null) */ public function fetchAuthToken(callable $httpHandler = null) { - return $this->sourceCredentials->fetchAuthToken($httpHandler); + // We don't support id token endpoint requests as of now for Impersonated Cred + return $this->sourceCredentials->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics([], 'at') + ); } /** @@ -139,4 +145,9 @@ public function getLastReceivedToken() { return $this->sourceCredentials->getLastReceivedToken(); } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index eba43cf9f..91238029d 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -65,6 +65,13 @@ class ServiceAccountCredentials extends CredentialsLoader implements { use ServiceAccountSignerTrait; + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'sa'; + /** * The OAuth2 instance used to conduct authorization. * @@ -206,7 +213,9 @@ public function fetchAuthToken(callable $httpHandler = null) return $accessToken; } - return $this->auth->fetchAuthToken($httpHandler); + $authRequestType = empty($this->auth->getAdditionalClaims()['target_audience']) + ? 'at' : 'it'; + return $this->auth->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], $authRequestType)); } /** @@ -344,6 +353,11 @@ public function getUniverseDomain(): string return $this->universeDomain; } + protected function getCredType(): string + { + return self::CRED_TYPE; + } + /** * @return bool */ diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index 8b2fb9454..87baa7500 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -40,6 +40,13 @@ class ServiceAccountJwtAccessCredentials extends CredentialsLoader implements { use ServiceAccountSignerTrait; + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'jwt'; + /** * The OAuth2 instance used to conduct authorization. * @@ -209,4 +216,9 @@ public function getQuotaProject() { return $this->quotaProject; } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index e2f32d87f..69778f7c8 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -34,6 +34,13 @@ */ class UserRefreshCredentials extends CredentialsLoader implements GetQuotaProjectInterface { + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'u'; + /** * The OAuth2 instance used to conduct authorization. * @@ -98,6 +105,10 @@ public function __construct( /** * @param callable $httpHandler + * @param array $metricsHeader [optional] Metrics headers to be inserted + * into the token endpoint request present. + * This could be passed from ImersonatedServiceAccountCredentials as it uses + * UserRefreshCredentials as source credentials. * * @return array { * A set of auth related metadata, containing the following @@ -109,9 +120,13 @@ public function __construct( * @type string $id_token * } */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, array $metricsHeader = []) { - return $this->auth->fetchAuthToken($httpHandler); + // We don't support id token endpoint requests as of now for User Cred + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($metricsHeader, 'at') + ); } /** @@ -149,4 +164,9 @@ public function getGrantedScope() { return $this->auth->getGrantedScope(); } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/MetricsTrait.php b/src/MetricsTrait.php new file mode 100644 index 000000000..8d5c03cf8 --- /dev/null +++ b/src/MetricsTrait.php @@ -0,0 +1,120 @@ + $metadata The metadata to update and return. + * @return array The updated metadata. + */ + protected function applyServiceApiUsageMetrics($metadata) + { + if ($credType = $this->getCredType()) { + // Add service api usage observability metrics info into metadata + // We expect upstream libries to have the metadata key populated already + $value = 'cred-type/' . $credType; + if (!isset($metadata[self::$metricMetadataKey])) { + // This case will happen only when someone invokes the updateMetadata + // method on the credentials fetcher themselves. + $metadata[self::$metricMetadataKey] = [$value]; + } elseif (is_array($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey][0] .= ' ' . $value; + } else { + $metadata[self::$metricMetadataKey] .= ' ' . $value; + } + } + + return $metadata; + } + + /** + * @param array $metadata The metadata to update and return. + * @param string $authRequestType The auth request type. Possible values are + * `'at'`, `'it'`, `'mds'`. + * @return array The updated metadata. + */ + protected function applyTokenEndpointMetrics($metadata, $authRequestType) + { + $metricsHeader = self::getMetricsHeader($this->getCredType(), $authRequestType); + if (!isset($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey] = $metricsHeader; + } + return $metadata; + } + + protected static function getVersion(): string + { + if (is_null(self::$version)) { + $versionFilePath = __DIR__ . '/../VERSION'; + self::$version = trim((string) file_get_contents($versionFilePath)); + } + return self::$version; + } + + protected function getCredType(): string + { + return ''; + } +} diff --git a/src/OAuth2.php b/src/OAuth2.php index 5fc3ba80c..b1f9ae26d 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -582,9 +582,11 @@ public function toJwt(array $config = []) * Generates a request for token credentials. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Additional headers to pass to + * the token endpoint request. * @return RequestInterface the authorization Url. */ - public function generateCredentialsRequest(callable $httpHandler = null) + public function generateCredentialsRequest(callable $httpHandler = null, $headers = []) { $uri = $this->getTokenCredentialUri(); if (is_null($uri)) { @@ -646,7 +648,7 @@ public function generateCredentialsRequest(callable $httpHandler = null) $headers = [ 'Cache-Control' => 'no-store', 'Content-Type' => 'application/x-www-form-urlencoded', - ]; + ] + $headers; return new Request( 'POST', @@ -660,15 +662,17 @@ public function generateCredentialsRequest(callable $httpHandler = null) * Fetches the auth tokens based on the current state. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. * @return array the response */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, $headers = []) { if (is_null($httpHandler)) { $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } - $response = $httpHandler($this->generateCredentialsRequest($httpHandler)); + $response = $httpHandler($this->generateCredentialsRequest($httpHandler, $headers)); $credentials = $this->parseTokenResponse($response); $this->updateToken($credentials); if (isset($credentials['scope'])) { diff --git a/src/UpdateMetadataTrait.php b/src/UpdateMetadataTrait.php index fd33e0dca..30d4060cf 100644 --- a/src/UpdateMetadataTrait.php +++ b/src/UpdateMetadataTrait.php @@ -26,6 +26,8 @@ */ trait UpdateMetadataTrait { + use MetricsTrait; + /** * export a callback function which updates runtime metadata. * @@ -50,12 +52,18 @@ public function updateMetadata( $authUri = null, callable $httpHandler = null ) { - if (isset($metadata[self::AUTH_METADATA_KEY])) { + $metadata_copy = $metadata; + + // We do need to set the service api usage metrics irrespective even if + // the auth token is set because invoking this method with auth tokens + // would mean the intention is to just explicitly set the metrics metadata. + $metadata_copy = $this->applyServiceApiUsageMetrics($metadata_copy); + + if (isset($metadata_copy[self::AUTH_METADATA_KEY])) { // Auth metadata has already been set - return $metadata; + return $metadata_copy; } $result = $this->fetchAuthToken($httpHandler); - $metadata_copy = $metadata; if (isset($result['access_token'])) { $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; } elseif (isset($result['id_token'])) { diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 5964b9a5c..cc1eb538f 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -52,6 +52,24 @@ public function testOnGceMetadataFlavorHeader() $this->assertTrue($onGce); } + public function testOnGceMetricsHeader() + { + $handerInvoked = false; + $dummyHandler = function ($request) use (&$handerInvoked) { + $header = $request->getHeaderLine('x-goog-api-client'); + $handerInvoked = true; + $this->assertStringMatchesFormat( + 'gl-php/%s auth/%s auth-request-type/mds', + $header + ); + + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + + GCECredentials::onGce($dummyHandler); + $this->assertTrue($handerInvoked); + } + public function testOnGCEIsFalseOnClientErrorStatus() { // simulate retry attempts by returning multiple 400s diff --git a/tests/MetricsTraitTest.php b/tests/MetricsTraitTest.php new file mode 100644 index 000000000..7c54cf6ec --- /dev/null +++ b/tests/MetricsTraitTest.php @@ -0,0 +1,63 @@ +impl = new class() { + use MetricsTrait{ + getVersion as public; + getMetricsHeader as public; + } + }; + } + + public function testGetVersion() + { + $actualVersion = $this->impl::getVersion(); + $this->assertStringMatchesFormat('%d.%d.%d', $actualVersion); + } + + /** + * @dataProvider metricsHeaderCases + */ + public function testGetMetricsHeader($credType, $authRequestType, $expected) + { + $headerValue = $this->impl::getMetricsHeader($credType, $authRequestType); + $this->assertStringMatchesFormat('gl-php/%s auth/%s ' . $expected, $headerValue); + } + + public function metricsHeaderCases() + { + return [ + ['foo', '', 'cred-type/foo'], + ['', 'bar', 'auth-request-type/bar'], + ['foo', 'bar', 'auth-request-type/bar cred-type/foo'] + ]; + } +} diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php new file mode 100644 index 000000000..450bfa125 --- /dev/null +++ b/tests/ObservabilityMetricsTest.php @@ -0,0 +1,203 @@ +langAndVersion = sprintf( + 'gl-php/%s auth/%s', + PHP_VERSION, + $updateMetadataTraitImpl::getVersion() + ); + $this->jsonTokens = json_encode(['access_token' => '1/abdef1234567890', 'expires_in' => '57']); + } + + /** + * @dataProvider tokenRequestType + */ + public function testGCECredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $handlerCalled = false; + $jsonTokens = $this->jsonTokens; + $handler = getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + function ($request, $options) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/mds', $this->langAndVersion, $requestTypeHeaderValue), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + } + ]); + + $gceCred = new GCECredentials(null, $scope, $targetAudience); + $this->assertUpdateMetadata($gceCred, $handler, 'mds', $handlerCalled); + } + + /** + * @dataProvider tokenRequestType + */ + public function testServiceAccountCredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $keyFile = __DIR__ . '/fixtures3/service_account_credentials.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('sa', $requestTypeHeaderValue, $handlerCalled); + + $sa = new ServiceAccountCredentials( + $scope, + $keyFile, + null, + $targetAudience + ); + $this->assertUpdateMetadata($sa, $handler, 'sa', $handlerCalled); + } + + /** + * ServiceAccountJwtAccessCredentials creates the jwt token within library hence + * they don't have any observability metrics header check for token endpoint requests. + */ + public function testServiceAccountJwtAccessCredentials() + { + $keyFile = __DIR__ . '/fixtures3/service_account_credentials.json'; + $saJwt = new ServiceAccountJwtAccessCredentials($keyFile, 'exampleScope'); + $metadata = $saJwt->updateMetadata([self::$headerKey => ['foo']], null, null); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/jwt'), + $metadata[self::$headerKey][0] + ); + } + + /** + * ImpersonatedServiceAccountCredentials haven't enabled identity token support hence + * they don't have 'auth-request-type/it' observability metric header check. + */ + public function testImpersonatedServiceAccountCredentials() + { + $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('imp', 'auth-request-type/at', $handlerCalled); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + + /** + * UserRefreshCredentials haven't enabled identity token support hence + * they don't have 'auth-request-type/it' observability metric header check. + */ + public function testUserRefreshCredentials() + { + $keyFile = __DIR__ . '/fixtures2/gcloud.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('u', 'auth-request-type/at', $handlerCalled); + + $userRefreshCred = new UserRefreshCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($userRefreshCred, $handler, 'u', $handlerCalled); + } + + /** + * Invokes the 'updateMetadata' method of cred fetcher with empty metadata argument + * and asserts for proper service api usage observability metrics header. + */ + private function assertUpdateMetadata($cred, $handler, $credShortform, &$handlerCalled) + { + $metadata = $cred->updateMetadata([self::$headerKey => ['foo']], null, $handler); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/%s', $credShortform), + $metadata[self::$headerKey][0] + ); + + $this->assertTrue($handlerCalled); + } + + /** + * @param string $credShortform The short form of the credential type + * used in observability metric header value. + * @param string $requestTypeHeaderValue Expected header value of the form + * 'auth-request-type/<>' + * @param bool $handlerCalled Reference to the handlerCalled flag asserted later + * in the test. + * @return callable + */ + private function getCustomHandler($credShortform, $requestTypeHeaderValue, &$handlerCalled) + { + $jsonTokens = $this->jsonTokens; + return getHandler([ + function ($request, $options) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue, + $credShortform + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/%s', $this->langAndVersion, $requestTypeHeaderValue, $credShortform), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + } + ]); + } + + public function tokenRequestType() + { + return [ + ['someScope', null, 'auth-request-type/at'], + [null, 'someTargetAudience', 'auth-request-type/it'], + ]; + } +}