Repository: prometheus/common
Pull request #287 (2021-04-11 15:36:27)

OAuth 2.0 Client
This is a client implementation of the OAuth 2.0 protocol, specifically for the `client_credentials` grant type. It's intended for use as an authentication method for Prometheus remote write.

roidelapluie: Thanks.

Why didn't you use the upstream oauth  library? It seems better and also have roundtrippers we can integrate in our http client.
LeviHarrison: I'm so sorry, I though you meant I was to implement this using _only_ the standard library.

>>> Additionally, I'd like to have this with the golang stdlib and in the prometheus/common repository.
roidelapluie: https://pkg.go.dev/golang.org/x/oauth2 is what I call stdlib, sorry for the confusion :(
LeviHarrison: I don't think we can use the `oauth2` RoundTrippers because they don't support `next`-chaining, so we'll have to implement our own. Besides that, I think this package should provide a configuration with YAML aliases, and an initialization function that accepts that configuration and returns a RoundTripper. Does that sound good?
roidelapluie: I am not following, oauth2 allow setting a `base` roundtripper.
LeviHarrison: Interesting, I didn't see that. I guess, when added to Prometheus, would could just use the RoundTripper chained together here as the base:

```go
	t := httpClient.Transport

	if conf.SigV4Config != nil {
		t, err = newSigV4RoundTripper(conf.SigV4Config, httpClient.Transport)
		if err != nil {
			return nil, err
		}
	}

	if len(conf.Headers) > 0 {
		t = newInjectHeadersRoundTripper(conf.Headers, t)
	}

	httpClient.Transport = &nethttp.Transport{
		RoundTripper: t,
	}
```

So, in that case, I think all this package would need to have is the YAML configuration and the initialization function. Right?
roidelapluie: I think so yes
LeviHarrison: Ok switched over to the `oauth2` package :)
roidelapluie: Can you please integrate this in the http client?

Thanks!
LeviHarrison: Do you mean returning an HTTP client?
roidelapluie: I mean using your new oauth package in https://github.com/prometheus/common/blob/main/config/http_config.go
LeviHarrison: Done!

Patch:
diff --git a/config/http_config.go b/config/http_config.go
index b3726da..cae49b9 100644
--- a/config/http_config.go
+++ b/config/http_config.go
@@ -32,6 +32,8 @@ import (
 
 	"github.com/mwitkow/go-conntrack"
 	"golang.org/x/net/http2"
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/clientcredentials"
 	"gopkg.in/yaml.v2"
 )
 
@@ -108,12 +110,23 @@ func (u URL) MarshalYAML() (interface{}, error) {
 	return nil, nil
 }
 
+// OAuth2 is the oauth2 client configuration.
+type OAuth2 struct {
+	ClientID       string            `yaml:"client_id"`
+	ClientSecret   Secret            `yaml:"client_secret"`
+	Scopes         []string          `yaml:"scopes,omitempty"`
+	TokenURL       string            `yaml:"token_url"`
+	EndpointParams map[string]string `yaml:"endpoint_params,omitempty"`
+}
+
 // HTTPClientConfig configures an HTTP client.
 type HTTPClientConfig struct {
 	// The HTTP basic authentication credentials for the targets.
 	BasicAuth *BasicAuth `yaml:"basic_auth,omitempty"`
 	// The HTTP authorization credentials for the targets.
 	Authorization *Authorization `yaml:"authorization,omitempty"`
+	// The OAuth2 client credentials used to fetch a token for the targets.
+	OAuth2 *OAuth2 `yaml:"oauth2,omitempty"`
 	// The bearer token for the targets. Deprecated in favour of
 	// Authorization.Credentials.
 	BearerToken Secret `yaml:"bearer_token,omitempty"`
@@ -148,8 +161,8 @@ func (c *HTTPClientConfig) Validate() error {
 	if len(c.BearerToken) > 0 && len(c.BearerTokenFile) > 0 {
 		return fmt.Errorf("at most one of bearer_token & bearer_token_file must be configured")
 	}
-	if c.BasicAuth != nil && (len(c.BearerToken) > 0 || len(c.BearerTokenFile) > 0) {
-		return fmt.Errorf("at most one of basic_auth, bearer_token & bearer_token_file must be configured")
+	if (c.BasicAuth != nil || c.OAuth2 != nil) && (len(c.BearerToken) > 0 || len(c.BearerTokenFile) > 0) {
+		return fmt.Errorf("at most one of basic_auth, oauth2, bearer_token & bearer_token_file must be configured")
 	}
 	if c.BasicAuth != nil && (string(c.BasicAuth.Password) != "" && c.BasicAuth.PasswordFile != "") {
 		return fmt.Errorf("at most one of basic_auth password & password_file must be configured")
@@ -168,8 +181,8 @@ func (c *HTTPClientConfig) Validate() error {
 		if strings.ToLower(c.Authorization.Type) == "basic" {
 			return fmt.Errorf(`authorization type cannot be set to "basic", use "basic_auth" instead`)
 		}
-		if c.BasicAuth != nil {
-			return fmt.Errorf("at most one of basic_auth & authorization must be configured")
+		if c.BasicAuth != nil || c.OAuth2 != nil {
+			return fmt.Errorf("at most one of basic_auth, oauth2 & authorization must be configured")
 		}
 	} else {
 		if len(c.BearerToken) > 0 {
@@ -183,6 +196,9 @@ func (c *HTTPClientConfig) Validate() error {
 			c.BearerTokenFile = ""
 		}
 	}
+	if c.BasicAuth != nil && c.OAuth2 != nil {
+		return fmt.Errorf("at most one of basic_auth, oauth2 & authorization must be configured")
+	}
 	return nil
 }
 
@@ -329,6 +345,10 @@ func NewRoundTripperFromConfig(cfg HTTPClientConfig, name string, optFuncs ...HT
 		if cfg.BasicAuth != nil {
 			rt = NewBasicAuthRoundTripper(cfg.BasicAuth.Username, cfg.BasicAuth.Password, cfg.BasicAuth.PasswordFile, rt)
 		}
+
+		if cfg.OAuth2 != nil {
+			rt = cfg.OAuth2.NewOAuth2RoundTripper(context.Background(), rt)
+		}
 		// Return a new configured RoundTripper.
 		return rt, nil
 	}
@@ -442,6 +462,32 @@ func (rt *basicAuthRoundTripper) CloseIdleConnections() {
 	}
 }
 
+func (c *OAuth2) NewOAuth2RoundTripper(ctx context.Context, next http.RoundTripper) http.RoundTripper {
+	config := &clientcredentials.Config{
+		ClientID:       c.ClientID,
+		ClientSecret:   string(c.ClientSecret),
+		Scopes:         c.Scopes,
+		TokenURL:       c.TokenURL,
+		EndpointParams: mapToValues(c.EndpointParams),
+	}
+
+	tokenSource := config.TokenSource(ctx)
+
+	return &oauth2.Transport{
+		Base:   next,
+		Source: tokenSource,
+	}
+}
+
+func mapToValues(m map[string]string) url.Values {
+	v := url.Values{}
+	for name, value := range m {
+		v.Set(name, value)
+	}
+
+	return v
+}
+
 // cloneRequest returns a clone of the provided *http.Request.
 // The clone is a shallow copy of the struct and its Header map.
 func cloneRequest(r *http.Request) *http.Request {
diff --git a/go.mod b/go.mod
index f5b5bb5..bd5c7e7 100644
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
 	github.com/prometheus/client_model v0.2.0
 	github.com/sirupsen/logrus v1.6.0
 	golang.org/x/net v0.0.0-20200625001655-4c5254603344
+	golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421
 	golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae
 	gopkg.in/alecthomas/kingpin.v2 v2.2.6
 	gopkg.in/yaml.v2 v2.3.0
diff --git a/go.sum b/go.sum
index 4244433..11a93be 100644
--- a/go.sum
+++ b/go.sum
@@ -313,6 +313,7 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -365,6 +366,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=


Test patch:
diff --git a/config/http_config_test.go b/config/http_config_test.go
index a0511dd..4810ea5 100644
--- a/config/http_config_test.go
+++ b/config/http_config_test.go
@@ -19,6 +19,7 @@ import (
 	"context"
 	"crypto/tls"
 	"crypto/x509"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io/ioutil"
@@ -76,7 +77,7 @@ var invalidHTTPClientConfigs = []struct {
 	},
 	{
 		httpClientConfigFile: "testdata/http.conf.empty.bad.yml",
-		errMsg:               "at most one of basic_auth, bearer_token & bearer_token_file must be configured",
+		errMsg:               "at most one of basic_auth, oauth2, bearer_token & bearer_token_file must be configured",
 	},
 	{
 		httpClientConfigFile: "testdata/http.conf.basic-auth.too-much.bad.yaml",
@@ -92,7 +93,11 @@ var invalidHTTPClientConfigs = []struct {
 	},
 	{
 		httpClientConfigFile: "testdata/http.conf.basic-auth-and-auth-creds.too-much.bad.yaml",
-		errMsg:               "at most one of basic_auth & authorization must be configured",
+		errMsg:               "at most one of basic_auth, oauth2 & authorization must be configured",
+	},
+	{
+		httpClientConfigFile: "testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml",
+		errMsg:               "at most one of basic_auth, oauth2 & authorization must be configured",
 	},
 	{
 		httpClientConfigFile: "testdata/http.conf.auth-creds-no-basic.bad.yaml",
@@ -1087,3 +1092,59 @@ func NewRoundTripCheckRequest(checkRequest func(*http.Request), theResponse *htt
 			theResponse: theResponse,
 			theError:    theError}}
 }
+
+type testServerResponse struct {
+	AccessToken string `json:"access_token"`
+	TokenType   string `json:"token_type"`
+}
+
+func TestOAuth2(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		res, _ := json.Marshal(testServerResponse{
+			AccessToken: "12345",
+			TokenType:   "Bearer",
+		})
+		w.Header().Add("Content-Type", "application/json")
+		_, _ = w.Write(res)
+	}))
+	defer ts.Close()
+
+	var yamlConfig = fmt.Sprintf(`
+client_id: 1
+client_secret: 2
+scopes:
+ - A
+ - B
+token_url: %s
+endpoint_params:
+ hi: hello
+`, ts.URL)
+	expectedConfig := OAuth2{
+		ClientID:       "1",
+		ClientSecret:   "2",
+		Scopes:         []string{"A", "B"},
+		EndpointParams: map[string]string{"hi": "hello"},
+		TokenURL:       ts.URL,
+	}
+
+	var unmarshalledConfig OAuth2
+	err := yaml.Unmarshal([]byte(yamlConfig), &unmarshalledConfig)
+	if err != nil {
+		t.Fatalf("Expected no error unmarshalling yaml, got %v", err)
+	}
+	if !reflect.DeepEqual(unmarshalledConfig, expectedConfig) {
+		t.Fatalf("Got unmarshalled config %q, expected %q", unmarshalledConfig, expectedConfig)
+	}
+
+	rt := expectedConfig.NewOAuth2RoundTripper(context.Background(), http.DefaultTransport)
+
+	client := http.Client{
+		Transport: rt,
+	}
+	resp, _ := client.Get(ts.URL)
+
+	authorization := resp.Request.Header.Get("Authorization")
+	if authorization != "Bearer 12345" {
+		t.Fatalf("Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
+	}
+}
diff --git a/config/testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml b/config/testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml
new file mode 100644
index 0000000..62a3088
--- /dev/null
+++ b/config/testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml
@@ -0,0 +1,6 @@
+basic_auth:
+  username: user
+  password: foo
+oauth2:
+  client_id: foo
+  client_secret: bar