From c4255e65bc5aa50ae39320ceefc7237a303eb24c Mon Sep 17 00:00:00 2001 From: Andre Bindewald Date: Thu, 10 Mar 2022 02:53:51 +0100 Subject: [PATCH] feat(client): OAuth2 Client credential support (#259) * Initial implementation * Added OAuth2 support to `client` config * Revert "Initial implementation" This reverts commit 7f2f3a603ae018b1cd1c6a282104f44cd9a1a1d1. * Restore vendored clientcredentials * configureOAuth2 is now a func (including tests) * README update * Use the same OAuth2Config in all related tests * Cleanup & comments --- README.md | 29 ++++- client/client_test.go | 79 ++++++++++++ client/config.go | 53 +++++++- core/endpoint.go | 5 +- .../clientcredentials/clientcredentials.go | 120 ++++++++++++++++++ vendor/modules.txt | 1 + 6 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go diff --git a/README.md b/README.md index 0e32bd7f..18de1ff4 100644 --- a/README.md +++ b/README.md @@ -273,11 +273,16 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres In order to support a wide range of environments, each monitored endpoint has a unique configuration for the client used to send the request. -| Parameter | Description | Default | -|:-------------------------|:------------------------------------------------------------------------|:--------| -| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | -| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` | -| `client.timeout` | Duration before timing out. | `10s` | +| Parameter | Description | Default | +|:------------------------------|:---------------------------------------------------------------------------|:----------------| +| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | +| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` | +| `client.timeout` | Duration before timing out. | `10s` | +| `client.oauth2` | OAuth2 client configuration. | `{}` | +| `client.oauth2.token-url` | The token endpoint URL | required `""` | +| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` | +| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` | +| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` | Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. @@ -304,6 +309,20 @@ endpoints: - "[STATUS] == 200" ``` +This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`: +```yaml +endpoints: + - name: website + url: "https://your.health.api/getHealth" + client: + oauth2: + token-url: https://your-token-server/token + client-id: 00000000-0000-0000-0000-000000000000 + client-secret: your-client-secret + scopes: ['https://your.health.api/.default'] + conditions: + - "[STATUS] == 200" +``` ### Alerting Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each diff --git a/client/client_test.go b/client/client_test.go index 76191e63..7e8dc311 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1,8 +1,13 @@ package client import ( + "bytes" + "io/ioutil" + "net/http" "testing" "time" + + "github.com/TwiN/gatus/v3/test" ) func TestGetHTTPClient(t *testing.T) { @@ -10,6 +15,12 @@ func TestGetHTTPClient(t *testing.T) { Insecure: false, IgnoreRedirect: false, Timeout: 0, + OAuth2Config: &OAuth2Config{ + ClientID: "00000000-0000-0000-0000-000000000000", + ClientSecret: "secretsauce", + TokenURL: "https://token-server.local/token", + Scopes: []string{"https://application.local/.default"}, + }, } cfg.ValidateAndSetDefaults() if GetHTTPClient(cfg) == nil { @@ -146,3 +157,71 @@ func TestCanCreateTCPConnection(t *testing.T) { t.Error("should've failed, because there's no port in the address") } } + +// This test checks if a HTTP client configured with `configureOAuth2()` automatically +// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization` +// header to all outgoing HTTP calls. +func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) { + + defer InjectHTTPClient(nil) + + oAuth2Config := &OAuth2Config{ + ClientID: "00000000-0000-0000-0000-000000000000", + ClientSecret: "secretsauce", + TokenURL: "https://token-server.local/token", + Scopes: []string{"https://application.local/.default"}, + } + + mockHttpClient := &http.Client{ + Transport: test.MockRoundTripper(func(r *http.Request) *http.Response { + + // if the mock HTTP client tries to get a token from the `token-server` + // we provide the expected token response + if r.Host == "token-server.local" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader( + []byte( + `{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`, + ), + )), + } + } + + // to verify the headers were sent as expected, we echo them back in the + // `X-Org-Authorization` header and check if the token value matches our + // mocked `token-server` response + return &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{ + "X-Org-Authorization": {r.Header.Get("Authorization")}, + }, + Body: http.NoBody, + } + }), + } + + mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config) + InjectHTTPClient(mockHttpClientWithOAuth) + + request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody) + if err != nil { + t.Error("expected no error, got", err.Error()) + } + + response, err := mockHttpClientWithOAuth.Do(request) + if err != nil { + t.Error("expected no error, got", err.Error()) + } + + if response.Header == nil { + t.Error("expected response headers, but got nil") + } + + // the mock response echos the Authorization header used in the request back + // to us as `X-Org-Authorization` header, we check here if the value matches + // our expected token `secret-token` + if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" { + t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization")) + } +} diff --git a/client/config.go b/client/config.go index 9dcdff9d..3ea7a762 100644 --- a/client/config.go +++ b/client/config.go @@ -1,9 +1,14 @@ package client import ( + "context" "crypto/tls" + "errors" "net/http" "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" ) const ( @@ -17,6 +22,10 @@ var ( IgnoreRedirect: false, Timeout: defaultHTTPTimeout, } + + ErrInvalidClientOAuth2Config = errors.New( + "invalid OAuth2 configuration, all fields are required", + ) ) // GetDefaultConfig returns a copy of the default configuration @@ -36,14 +45,40 @@ type Config struct { // Timeout for the client Timeout time.Duration `yaml:"timeout"` + // OAuth2 configuration for the client + OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"` + httpClient *http.Client } +// OAuth2Config is the configuration for the OAuth2 client credentials flow +type OAuth2Config struct { + TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token + ClientID string `yaml:"client-id"` + ClientSecret string `yaml:"client-secret"` + Scopes []string `yaml:"scopes"` // e.g. ["openid"] +} + // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary -func (c *Config) ValidateAndSetDefaults() { +func (c *Config) ValidateAndSetDefaults() error { if c.Timeout < time.Millisecond { c.Timeout = 10 * time.Second } + if c.HasOAuth2Config() && !c.OAuth2Config.isValid() { + return ErrInvalidClientOAuth2Config + } + + return nil +} + +// HasOAuth2Config returns true if the client has OAuth2 configuration parameters +func (c *Config) HasOAuth2Config() bool { + return c.OAuth2Config != nil +} + +// isValid() returns true if the OAuth2 configuration is valid +func (c *OAuth2Config) isValid() bool { + return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0 } // GetHTTPClient return an HTTP client matching the Config's parameters. @@ -68,6 +103,22 @@ func (c *Config) getHTTPClient() *http.Client { return nil }, } + if c.HasOAuth2Config() { + c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config) + } } return c.httpClient } + +// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary. +// The returned Client and its Transport should not be modified. +func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client { + oauth2cfg := clientcredentials.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Scopes: c.Scopes, + TokenURL: c.TokenURL, + } + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + return oauth2cfg.Client(ctx) +} diff --git a/core/endpoint.go b/core/endpoint.go index 1b908ae2..8bdf5d11 100644 --- a/core/endpoint.go +++ b/core/endpoint.go @@ -111,7 +111,10 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { if endpoint.ClientConfig == nil { endpoint.ClientConfig = client.GetDefaultConfig() } else { - endpoint.ClientConfig.ValidateAndSetDefaults() + err := endpoint.ClientConfig.ValidateAndSetDefaults() + if err != nil { + return err + } } if endpoint.UIConfig == nil { endpoint.UIConfig = ui.GetDefaultConfig() diff --git a/vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go b/vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go new file mode 100644 index 00000000..7a0b9ed1 --- /dev/null +++ b/vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go @@ -0,0 +1,120 @@ +// Copyright 2014 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 clientcredentials implements the OAuth2.0 "client credentials" token flow, +// also known as the "two-legged OAuth 2.0". +// +// This should be used when the client is acting on its own behalf or when the client +// is the resource owner. It may also be used when requesting access to protected +// resources based on an authorization previously arranged with the authorization +// server. +// +// See https://tools.ietf.org/html/rfc6749#section-4.4 +package clientcredentials // import "golang.org/x/oauth2/clientcredentials" + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/internal" +) + +// Config describes a 2-legged OAuth2 flow, with both the +// client application information and the server's endpoint URLs. +type Config struct { + // ClientID is the application's ID. + ClientID string + + // ClientSecret is the application's secret. + ClientSecret string + + // TokenURL is the resource server's token endpoint + // URL. This is a constant specific to each server. + TokenURL string + + // Scope specifies optional requested permissions. + Scopes []string + + // EndpointParams specifies additional parameters for requests to the token endpoint. + EndpointParams url.Values + + // AuthStyle optionally specifies how the endpoint wants the + // client ID & client secret sent. The zero value means to + // auto-detect. + AuthStyle oauth2.AuthStyle +} + +// Token uses client credentials to retrieve a token. +// +// The provided context optionally controls which HTTP client is used. See the oauth2.HTTPClient variable. +func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) { + return c.TokenSource(ctx).Token() +} + +// Client returns an HTTP client using the provided token. +// The token will auto-refresh as necessary. +// +// The provided context optionally controls which HTTP client +// is returned. See the oauth2.HTTPClient variable. +// +// The returned Client and its Transport should not be modified. +func (c *Config) Client(ctx context.Context) *http.Client { + return oauth2.NewClient(ctx, c.TokenSource(ctx)) +} + +// TokenSource returns a TokenSource that returns t until t expires, +// automatically refreshing it as necessary using the provided context and the +// client ID and client secret. +// +// Most users will use Config.Client instead. +func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { + source := &tokenSource{ + ctx: ctx, + conf: c, + } + return oauth2.ReuseTokenSource(nil, source) +} + +type tokenSource struct { + ctx context.Context + conf *Config +} + +// Token refreshes the token by using a new client credentials request. +// tokens received this way do not include a refresh token +func (c *tokenSource) Token() (*oauth2.Token, error) { + v := url.Values{ + "grant_type": {"client_credentials"}, + } + if len(c.conf.Scopes) > 0 { + v.Set("scope", strings.Join(c.conf.Scopes, " ")) + } + for k, p := range c.conf.EndpointParams { + // Allow grant_type to be overridden to allow interoperability with + // non-compliant implementations. + if _, ok := v[k]; ok && k != "grant_type" { + return nil, fmt.Errorf("oauth2: cannot overwrite parameter %q", k) + } + v[k] = p + } + + tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v, internal.AuthStyle(c.conf.AuthStyle)) + if err != nil { + if rErr, ok := err.(*internal.RetrieveError); ok { + return nil, (*oauth2.RetrieveError)(rErr) + } + return nil, err + } + t := &oauth2.Token{ + AccessToken: tk.AccessToken, + TokenType: tk.TokenType, + RefreshToken: tk.RefreshToken, + Expiry: tk.Expiry, + } + return t.WithExtra(tk.Raw), nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index ef9af5b2..78594b4d 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -116,6 +116,7 @@ golang.org/x/net/ipv6 # golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c ## explicit; go 1.11 golang.org/x/oauth2 +golang.org/x/oauth2/clientcredentials golang.org/x/oauth2/internal # golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ## explicit