mirror of
https://github.com/TwiN/gatus.git
synced 2024-12-15 17:51:09 +00:00
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:
parent
fcf046cbe8
commit
c4255e65bc
6 changed files with 280 additions and 7 deletions
21
README.md
21
README.md
|
@ -274,10 +274,15 @@ In order to support a wide range of environments, each monitored endpoint has a
|
||||||
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
|
||||||
|
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
120
vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go
generated
vendored
Normal file
120
vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go
generated
vendored
Normal 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
1
vendor/modules.txt
vendored
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue