From 10f0b6744a79e637abd61706c30692a74ebbc9f5 Mon Sep 17 00:00:00 2001 From: Levi Harrison Date: Mon, 26 Apr 2021 11:31:16 -0400 Subject: [PATCH] OAuth 2.0 Client (#287) * Added OAuth 2 Signed-off-by: Levi Harrison --- config/http_config.go | 54 +++++++++++++-- config/http_config_test.go | 65 ++++++++++++++++++- ...nf.basic-auth-and-oauth2.too-much.bad.yaml | 6 ++ go.mod | 1 + go.sum | 2 + 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 config/testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml diff --git a/config/http_config.go b/config/http_config.go index b3726da9..cae49b9d 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/config/http_config_test.go b/config/http_config_test.go index a0511dd2..4810ea5e 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 00000000..62a3088c --- /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 diff --git a/go.mod b/go.mod index f5b5bb5f..bd5c7e70 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 42444333..11a93be8 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=