google/internal/externalaccount: adding BYOID Metrics

Adds framework for sending BYOID metrics via the x-goog-api-client header on outgoing sts requests. Also adds a header file for getting the current version of GoLang

Change-Id: Id5431def96f4cfc03e4ada01d5fb8cac8cfa56a9
GitHub-Last-Rev: c93cd478e5fade98bcf846164b9b56f89b442f6b
GitHub-Pull-Request: golang/oauth2#661
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/523595
Reviewed-by: Leo Siracusa <leosiracusa@google.com>
Run-TryBot: Cody Oss <codyoss@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Cody Oss <codyoss@google.com>
diff --git a/google/internal/externalaccount/aws.go b/google/internal/externalaccount/aws.go
index a47b6de..bd4efd1 100644
--- a/google/internal/externalaccount/aws.go
+++ b/google/internal/externalaccount/aws.go
@@ -296,6 +296,10 @@
 	return !canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment()
 }
 
+func (cs awsCredentialSource) credentialSourceType() string {
+	return "aws"
+}
+
 func (cs awsCredentialSource) subjectToken() (string, error) {
 	if cs.requestSigner == nil {
 		headers := make(map[string]string)
diff --git a/google/internal/externalaccount/aws_test.go b/google/internal/externalaccount/aws_test.go
index fd962a4..28dc528 100644
--- a/google/internal/externalaccount/aws_test.go
+++ b/google/internal/externalaccount/aws_test.go
@@ -1234,3 +1234,20 @@
 		t.Errorf("subjectToken = \n%q\n want \n%q", got, want)
 	}
 }
+
+func TestAwsCredential_CredentialSourceType(t *testing.T) {
+	server := createDefaultAwsTestServer()
+	ts := httptest.NewServer(server)
+
+	tfc := testFileConfig
+	tfc.CredentialSource = server.getCredentialSource(ts.URL)
+
+	base, err := tfc.parse(context.Background())
+	if err != nil {
+		t.Fatalf("parse() failed %v", err)
+	}
+
+	if got, want := base.credentialSourceType(), "aws"; got != want {
+		t.Errorf("got %v but want %v", got, want)
+	}
+}
diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go
index 7c4c2b0..f58fb25 100644
--- a/google/internal/externalaccount/basecredentials.go
+++ b/google/internal/externalaccount/basecredentials.go
@@ -198,6 +198,7 @@
 }
 
 type baseCredentialSource interface {
+	credentialSourceType() string
 	subjectToken() (string, error)
 }
 
@@ -207,6 +208,15 @@
 	conf *Config
 }
 
+func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string {
+	return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
+		goVersion(),
+		"unknown",
+		credSource.credentialSourceType(),
+		conf.ServiceAccountImpersonationURL != "",
+		conf.ServiceAccountImpersonationLifetimeSeconds != 0)
+}
+
 // Token allows tokenSource to conform to the oauth2.TokenSource interface.
 func (ts tokenSource) Token() (*oauth2.Token, error) {
 	conf := ts.conf
@@ -230,6 +240,7 @@
 	}
 	header := make(http.Header)
 	header.Add("Content-Type", "application/x-www-form-urlencoded")
+	header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource))
 	clientAuth := clientAuthentication{
 		AuthStyle:    oauth2.AuthStyleInHeader,
 		ClientID:     conf.ClientID,
diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/internal/externalaccount/basecredentials_test.go
index bf6be32..9bdf8e0 100644
--- a/google/internal/externalaccount/basecredentials_test.go
+++ b/google/internal/externalaccount/basecredentials_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
@@ -51,6 +52,7 @@
 	url           string
 	authorization string
 	contentType   string
+	metricsHeader string
 	body          string
 	response      string
 }
@@ -68,6 +70,10 @@
 		if got, want := headerContentType, tets.contentType; got != want {
 			t.Errorf("got %v but want %v", got, want)
 		}
+		headerMetrics := r.Header.Get("x-goog-api-client")
+		if got, want := headerMetrics, tets.metricsHeader; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
 		body, err := ioutil.ReadAll(r.Body)
 		if err != nil {
 			t.Fatalf("Failed reading request body: %s.", err)
@@ -106,6 +112,10 @@
 	}
 }
 
+func getExpectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string {
+	return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime)
+}
+
 func TestToken(t *testing.T) {
 	config := Config{
 		Audience:         "32555940559.apps.googleusercontent.com",
@@ -120,6 +130,7 @@
 		url:           "/",
 		authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
 		contentType:   "application/x-www-form-urlencoded",
+		metricsHeader: getExpectedMetricsHeader("file", false, false),
 		body:          baseCredsRequestBody,
 		response:      baseCredsResponseBody,
 	}
@@ -147,6 +158,7 @@
 		url:           "/",
 		authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
 		contentType:   "application/x-www-form-urlencoded",
+		metricsHeader: getExpectedMetricsHeader("file", false, false),
 		body:          workforcePoolRequestBodyWithClientId,
 		response:      baseCredsResponseBody,
 	}
@@ -173,6 +185,7 @@
 		url:           "/",
 		authorization: "",
 		contentType:   "application/x-www-form-urlencoded",
+		metricsHeader: getExpectedMetricsHeader("file", false, false),
 		body:          workforcePoolRequestBodyWithoutClientId,
 		response:      baseCredsResponseBody,
 	}
diff --git a/google/internal/externalaccount/executablecredsource.go b/google/internal/externalaccount/executablecredsource.go
index 579bcce..6497dc0 100644
--- a/google/internal/externalaccount/executablecredsource.go
+++ b/google/internal/externalaccount/executablecredsource.go
@@ -233,6 +233,10 @@
 	return "", tokenTypeError(source)
 }
 
+func (cs executableCredentialSource) credentialSourceType() string {
+	return "executable"
+}
+
 func (cs executableCredentialSource) subjectToken() (string, error) {
 	if token, err := cs.getTokenFromOutputFile(); token != "" || err != nil {
 		return token, err
diff --git a/google/internal/externalaccount/executablecredsource_test.go b/google/internal/externalaccount/executablecredsource_test.go
index 074dfc4..df8a906 100644
--- a/google/internal/externalaccount/executablecredsource_test.go
+++ b/google/internal/externalaccount/executablecredsource_test.go
@@ -150,6 +150,9 @@
 				if ecs.Timeout != tt.expectedTimeout {
 					t.Errorf("ecs.Timeout got %v but want %v", ecs.Timeout, tt.expectedTimeout)
 				}
+				if ecs.credentialSourceType() != "executable" {
+					t.Errorf("ecs.CredentialSourceType() got %s but want executable", ecs.credentialSourceType())
+				}
 			}
 		})
 	}
diff --git a/google/internal/externalaccount/filecredsource.go b/google/internal/externalaccount/filecredsource.go
index e953ddb..f35f73c 100644
--- a/google/internal/externalaccount/filecredsource.go
+++ b/google/internal/externalaccount/filecredsource.go
@@ -19,6 +19,10 @@
 	Format format
 }
 
+func (cs fileCredentialSource) credentialSourceType() string {
+	return "file"
+}
+
 func (cs fileCredentialSource) subjectToken() (string, error) {
 	tokenFile, err := os.Open(cs.File)
 	if err != nil {
diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/internal/externalaccount/filecredsource_test.go
index 553830d..c20700f 100644
--- a/google/internal/externalaccount/filecredsource_test.go
+++ b/google/internal/externalaccount/filecredsource_test.go
@@ -68,6 +68,9 @@
 				t.Errorf("got %v but want %v", out, test.want)
 			}
 
+			if got, want := base.credentialSourceType(), "file"; got != want {
+				t.Errorf("got %v but want %v", got, want)
+			}
 		})
 	}
 }
diff --git a/google/internal/externalaccount/header.go b/google/internal/externalaccount/header.go
new file mode 100644
index 0000000..1d5aad2
--- /dev/null
+++ b/google/internal/externalaccount/header.go
@@ -0,0 +1,64 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+	"runtime"
+	"strings"
+	"unicode"
+)
+
+var (
+	// version is a package internal global variable for testing purposes.
+	version = runtime.Version
+)
+
+// versionUnknown is only used when the runtime version cannot be determined.
+const versionUnknown = "UNKNOWN"
+
+// goVersion returns a Go runtime version derived from the runtime environment
+// that is modified to be suitable for reporting in a header, meaning it has no
+// whitespace. If it is unable to determine the Go runtime version, it returns
+// versionUnknown.
+func goVersion() string {
+	const develPrefix = "devel +"
+
+	s := version()
+	if strings.HasPrefix(s, develPrefix) {
+		s = s[len(develPrefix):]
+		if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 {
+			s = s[:p]
+		}
+		return s
+	} else if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 {
+		s = s[:p]
+	}
+
+	notSemverRune := func(r rune) bool {
+		return !strings.ContainsRune("0123456789.", r)
+	}
+
+	if strings.HasPrefix(s, "go1") {
+		s = s[2:]
+		var prerelease string
+		if p := strings.IndexFunc(s, notSemverRune); p >= 0 {
+			s, prerelease = s[:p], s[p:]
+		}
+		if strings.HasSuffix(s, ".") {
+			s += "0"
+		} else if strings.Count(s, ".") < 2 {
+			s += ".0"
+		}
+		if prerelease != "" {
+			// Some release candidates already have a dash in them.
+			if !strings.HasPrefix(prerelease, "-") {
+				prerelease = "-" + prerelease
+			}
+			s += prerelease
+		}
+		return s
+	}
+	return "UNKNOWN"
+}
diff --git a/google/internal/externalaccount/header_test.go b/google/internal/externalaccount/header_test.go
new file mode 100644
index 0000000..39f279d
--- /dev/null
+++ b/google/internal/externalaccount/header_test.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package externalaccount
+
+import (
+	"runtime"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestGoVersion(t *testing.T) {
+	testVersion := func(v string) func() string {
+		return func() string {
+			return v
+		}
+	}
+	for _, tst := range []struct {
+		v    func() string
+		want string
+	}{
+		{
+			testVersion("go1.19"),
+			"1.19.0",
+		},
+		{
+			testVersion("go1.21-20230317-RC01"),
+			"1.21.0-20230317-RC01",
+		},
+		{
+			testVersion("devel +abc1234"),
+			"abc1234",
+		},
+		{
+			testVersion("this should be unknown"),
+			versionUnknown,
+		},
+	} {
+		version = tst.v
+		got := goVersion()
+		if diff := cmp.Diff(got, tst.want); diff != "" {
+			t.Errorf("got(-),want(+):\n%s", diff)
+		}
+	}
+	version = runtime.Version
+}
diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go
index 8c7f6a9..0ab6d61 100644
--- a/google/internal/externalaccount/impersonate_test.go
+++ b/google/internal/externalaccount/impersonate_test.go
@@ -42,7 +42,7 @@
 	}))
 }
 
-func createTargetServer(t *testing.T) *httptest.Server {
+func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server {
 	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if got, want := r.URL.String(), "/"; got != want {
 			t.Errorf("URL.String(): got %v but want %v", got, want)
@@ -55,6 +55,10 @@
 		if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
 			t.Errorf("got %v but want %v", got, want)
 		}
+		headerMetrics := r.Header.Get("x-goog-api-client")
+		if got, want := headerMetrics, metricsHeaderWanted; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
 		body, err := ioutil.ReadAll(r.Body)
 		if err != nil {
 			t.Fatalf("Failed reading request body: %v.", err)
@@ -71,6 +75,7 @@
 	name                      string
 	config                    Config
 	expectedImpersonationBody string
+	expectedMetricsHeader     string
 }{
 	{
 		name: "Base Impersonation",
@@ -84,6 +89,7 @@
 			Scopes:           []string{"https://www.googleapis.com/auth/devstorage.full_control"},
 		},
 		expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
+		expectedMetricsHeader:     getExpectedMetricsHeader("file", true, false),
 	},
 	{
 		name: "With TokenLifetime Set",
@@ -98,6 +104,7 @@
 			ServiceAccountImpersonationLifetimeSeconds: 10000,
 		},
 		expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
+		expectedMetricsHeader:     getExpectedMetricsHeader("file", true, true),
 	},
 }
 
@@ -109,7 +116,7 @@
 			defer impersonateServer.Close()
 			testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL
 
-			targetServer := createTargetServer(t)
+			targetServer := createTargetServer(tt.expectedMetricsHeader, t)
 			defer targetServer.Close()
 			testImpersonateConfig.TokenURL = targetServer.URL
 
diff --git a/google/internal/externalaccount/urlcredsource.go b/google/internal/externalaccount/urlcredsource.go
index 16dca65..606bb4e 100644
--- a/google/internal/externalaccount/urlcredsource.go
+++ b/google/internal/externalaccount/urlcredsource.go
@@ -23,6 +23,10 @@
 	ctx     context.Context
 }
 
+func (cs urlCredentialSource) credentialSourceType() string {
+	return "url"
+}
+
 func (cs urlCredentialSource) subjectToken() (string, error) {
 	client := oauth2.NewClient(cs.ctx, nil)
 	req, err := http.NewRequest("GET", cs.URL, nil)
diff --git a/google/internal/externalaccount/urlcredsource_test.go b/google/internal/externalaccount/urlcredsource_test.go
index 6a36d0d..699f772 100644
--- a/google/internal/externalaccount/urlcredsource_test.go
+++ b/google/internal/externalaccount/urlcredsource_test.go
@@ -111,3 +111,21 @@
 		t.Errorf("got %v but want %v", out, myURLToken)
 	}
 }
+
+func TestURLCredential_CredentialSourceType(t *testing.T) {
+	cs := CredentialSource{
+		URL:    "http://example.com",
+		Format: format{Type: fileTypeText},
+	}
+	tfc := testFileConfig
+	tfc.CredentialSource = cs
+
+	base, err := tfc.parse(context.Background())
+	if err != nil {
+		t.Fatalf("parse() failed %v", err)
+	}
+
+	if got, want := base.credentialSourceType(), "url"; got != want {
+		t.Errorf("got %v but want %v", got, want)
+	}
+}