feat(client): OAuth2 Client credential support (#259)

* Initial implementation

* Added OAuth2 support to `client` config

* Revert "Initial implementation"

This reverts commit 7f2f3a603a.

* Restore vendored clientcredentials

* configureOAuth2 is now a func (including tests)

* README update

* Use the same OAuth2Config in all related tests

* Cleanup & comments
This commit is contained in:
Andre Bindewald 2022-03-10 02:53:51 +01:00 committed by GitHub
parent fcf046cbe8
commit c4255e65bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 280 additions and 7 deletions

View File

@ -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 In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request. the client used to send the request.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------|:------------------------------------------------------------------------|:--------| |:------------------------------|:---------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | | `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.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` | | `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 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. 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" - "[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 ### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each

View File

@ -1,8 +1,13 @@
package client package client
import ( import (
"bytes"
"io/ioutil"
"net/http"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/test"
) )
func TestGetHTTPClient(t *testing.T) { func TestGetHTTPClient(t *testing.T) {
@ -10,6 +15,12 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: 0, 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() cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil { 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") 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"))
}
}

View File

@ -1,9 +1,14 @@
package client package client
import ( import (
"context"
"crypto/tls" "crypto/tls"
"errors"
"net/http" "net/http"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
) )
const ( const (
@ -17,6 +22,10 @@ var (
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: defaultHTTPTimeout, Timeout: defaultHTTPTimeout,
} }
ErrInvalidClientOAuth2Config = errors.New(
"invalid OAuth2 configuration, all fields are required",
)
) )
// GetDefaultConfig returns a copy of the default configuration // GetDefaultConfig returns a copy of the default configuration
@ -36,14 +45,40 @@ type Config struct {
// Timeout for the client // Timeout for the client
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
// OAuth2 configuration for the client
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
httpClient *http.Client 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 // 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 { if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second 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. // GetHTTPClient return an HTTP client matching the Config's parameters.
@ -68,6 +103,22 @@ func (c *Config) getHTTPClient() *http.Client {
return nil return nil
}, },
} }
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}
} }
return c.httpClient 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)
}

View File

@ -111,7 +111,10 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if endpoint.ClientConfig == nil { if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig() endpoint.ClientConfig = client.GetDefaultConfig()
} else { } else {
endpoint.ClientConfig.ValidateAndSetDefaults() err := endpoint.ClientConfig.ValidateAndSetDefaults()
if err != nil {
return err
}
} }
if endpoint.UIConfig == nil { if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() endpoint.UIConfig = ui.GetDefaultConfig()

View File

@ -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
}

1
vendor/modules.txt vendored
View File

@ -116,6 +116,7 @@ golang.org/x/net/ipv6
# golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c # golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c
## explicit; go 1.11 ## explicit; go 1.11
golang.org/x/oauth2 golang.org/x/oauth2
golang.org/x/oauth2/clientcredentials
golang.org/x/oauth2/internal golang.org/x/oauth2/internal
# golang.org/x/sync v0.0.0-20210220032951-036812b2e83c # golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
## explicit ## explicit