From 18352fc4335d95cd4b558f7c914898f1c069754e Mon Sep 17 00:00:00 2001 From: aeitzman Date: Fri, 22 Sep 2023 20:09:05 +0000 Subject: [PATCH] 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 Run-TryBot: Cody Oss TryBot-Result: Gopher Robot Reviewed-by: Cody Oss --- google/internal/externalaccount/aws.go | 4 ++ google/internal/externalaccount/aws_test.go | 17 +++++ .../externalaccount/basecredentials.go | 11 ++++ .../externalaccount/basecredentials_test.go | 13 ++++ .../externalaccount/executablecredsource.go | 4 ++ .../executablecredsource_test.go | 3 + .../externalaccount/filecredsource.go | 4 ++ .../externalaccount/filecredsource_test.go | 3 + google/internal/externalaccount/header.go | 64 +++++++++++++++++++ .../internal/externalaccount/header_test.go | 48 ++++++++++++++ .../externalaccount/impersonate_test.go | 11 +++- .../internal/externalaccount/urlcredsource.go | 4 ++ .../externalaccount/urlcredsource_test.go | 18 ++++++ 13 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 google/internal/externalaccount/header.go create mode 100644 google/internal/externalaccount/header_test.go diff --git a/google/internal/externalaccount/aws.go b/google/internal/externalaccount/aws.go index a47b6de7f..bd4efd19b 100644 --- a/google/internal/externalaccount/aws.go +++ b/google/internal/externalaccount/aws.go @@ -296,6 +296,10 @@ func shouldUseMetadataServer() bool { 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 fd962a4a9..28dc5284b 100644 --- a/google/internal/externalaccount/aws_test.go +++ b/google/internal/externalaccount/aws_test.go @@ -1234,3 +1234,20 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin 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 7c4c2b045..f58fb25e0 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -198,6 +198,7 @@ func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { } type baseCredentialSource interface { + credentialSourceType() string subjectToken() (string, error) } @@ -207,6 +208,15 @@ type tokenSource struct { 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 @@ func (ts tokenSource) Token() (*oauth2.Token, error) { } 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 bf6be321c..9bdf8e01d 100644 --- a/google/internal/externalaccount/basecredentials_test.go +++ b/google/internal/externalaccount/basecredentials_test.go @@ -6,6 +6,7 @@ package externalaccount import ( "context" + "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -51,6 +52,7 @@ type testExchangeTokenServer struct { url string authorization string contentType string + metricsHeader string body string response string } @@ -68,6 +70,10 @@ func run(t *testing.T, config *Config, tets *testExchangeTokenServer) (*oauth2.T 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 validateToken(t *testing.T, tok *oauth2.Token) { } } +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 @@ func TestToken(t *testing.T) { url: "/", authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=", contentType: "application/x-www-form-urlencoded", + metricsHeader: getExpectedMetricsHeader("file", false, false), body: baseCredsRequestBody, response: baseCredsResponseBody, } @@ -147,6 +158,7 @@ func TestWorkforcePoolTokenWithClientID(t *testing.T) { url: "/", authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=", contentType: "application/x-www-form-urlencoded", + metricsHeader: getExpectedMetricsHeader("file", false, false), body: workforcePoolRequestBodyWithClientId, response: baseCredsResponseBody, } @@ -173,6 +185,7 @@ func TestWorkforcePoolTokenWithoutClientID(t *testing.T) { 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 579bcce5f..6497dc022 100644 --- a/google/internal/externalaccount/executablecredsource.go +++ b/google/internal/externalaccount/executablecredsource.go @@ -233,6 +233,10 @@ func (cs executableCredentialSource) parseSubjectTokenFromSource(response []byte 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 074dfc41b..df8a906b9 100644 --- a/google/internal/externalaccount/executablecredsource_test.go +++ b/google/internal/externalaccount/executablecredsource_test.go @@ -150,6 +150,9 @@ func TestCreateExecutableCredential(t *testing.T) { 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 e953ddb47..f35f73c5c 100644 --- a/google/internal/externalaccount/filecredsource.go +++ b/google/internal/externalaccount/filecredsource.go @@ -19,6 +19,10 @@ type fileCredentialSource struct { 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 553830dc9..c20700f1d 100644 --- a/google/internal/externalaccount/filecredsource_test.go +++ b/google/internal/externalaccount/filecredsource_test.go @@ -68,6 +68,9 @@ func TestRetrieveFileSubjectToken(t *testing.T) { 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 000000000..1d5aad2e2 --- /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 000000000..39f279deb --- /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 8c7f6a9a7..0ab6d6190 100644 --- a/google/internal/externalaccount/impersonate_test.go +++ b/google/internal/externalaccount/impersonate_test.go @@ -42,7 +42,7 @@ func createImpersonationServer(urlWanted, authWanted, bodyWanted, response strin })) } -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 @@ func createTargetServer(t *testing.T) *httptest.Server { 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 @@ var impersonationTests = []struct { name string config Config expectedImpersonationBody string + expectedMetricsHeader string }{ { name: "Base Impersonation", @@ -84,6 +89,7 @@ var impersonationTests = []struct { 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 @@ var impersonationTests = []struct { ServiceAccountImpersonationLifetimeSeconds: 10000, }, expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", + expectedMetricsHeader: getExpectedMetricsHeader("file", true, true), }, } @@ -109,7 +116,7 @@ func TestImpersonation(t *testing.T) { 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 16dca6541..606bb4e80 100644 --- a/google/internal/externalaccount/urlcredsource.go +++ b/google/internal/externalaccount/urlcredsource.go @@ -23,6 +23,10 @@ type urlCredentialSource struct { 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 6a36d0d75..699f7729e 100644 --- a/google/internal/externalaccount/urlcredsource_test.go +++ b/google/internal/externalaccount/urlcredsource_test.go @@ -111,3 +111,21 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { 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) + } +}