Skip to content

Commit

Permalink
[extension/oauth2clientauth] Enable dynamically reading ClientID and …
Browse files Browse the repository at this point in the history
…ClientSecret from files (#26310)

**Description:**
This PR implements the feature described in detail in the issue linked
below.

In a nutshell, it extends the `oauth2clientauth` extension to read
ClientID and/or ClientSecret from files whenever a new token is needed
for the OAuth flow.
As a result, the extension can use updated credentials (when the old
ones expire for example) without the need to restart the OTEL collector,
as long as the file contents are in sync.

**Link to tracking Issue:** #26117 

**Testing:**
Apart from the unit testing you can see in the PR, I've tested this
feature in two real-life environments:

1. As a systemd service exporting `otlphttp` data
2. A Kubernetes microservice (deployed by an OpenTelemetryCollector CR)
exporting `otlphttp` data

In both cases, the collectors export the data to a service which sits
behind an OIDC authentication proxy.
Using the `oauth2clientauth` extension, the `otlphttp` exporter hits the
authentication provider to issue tokens for the OIDC client and
successfully authenticates to the service.

In my cases, the ClientSecret gets rotated quite frequently and there is
a stack making sure the ClientID and ClientSecret in the corresponding
files are up-to-date.

**Documentation:**
I have extended the extension's README file. I'm open to more
suggestions!

cc @jpkrohling @pavankrish123
  • Loading branch information
elikatsis authored Sep 8, 2023
1 parent 75e29ce commit df03442
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 11 deletions.
29 changes: 29 additions & 0 deletions .chloggen/feature-oauth2clientauth-read-from-file.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
component: oauth2clientauthextension

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Enable dynamically reading ClientID and ClientSecret from files

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [26117]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext: |
- Read the client ID and/or secret from a file by specifying the file path to the ClientIDFile (`client_id_file`) and ClientSecretFile (`client_secret_file`) fields respectively.
- The file is read every time the client issues a new token. This means that the corresponding value can change dynamically during the execution by modifying the file contents.
# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: [user, api]
6 changes: 6 additions & 0 deletions extension/oauth2clientauthextension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ Following are the configuration fields
- [**token_url**](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2) - The resource server's token endpoint URLs.
- [**client_id**](https://datatracker.ietf.org/doc/html/rfc6749#section-2.2) - The client identifier issued to the client.
- **client_id_file** - The file path to retrieve the client identifier issued to the client.
The extension reads this file and updates the client ID used whenever it needs to issue a new token. This enables dynamically changing the client credentials by modifying the file contents when, for example, they need to rotate. <!-- Intended whitespace for compact new line -->
This setting takes precedence over `client_id`.
- [**client_secret**](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1) - The secret string associated with above identifier.
- **client_secret_file** - The file path to retrieve the secret string associated with above identifier.
The extension reads this file and updates the client secret used whenever it needs to issue a new token. This enables dynamically changing the client credentials by modifying the file contents when, for example, they need to rotate. <!-- Intended whitespace for compact new line -->
This setting takes precedence over `client_secret`.
- [**endpoint_params**](https://github.com/golang/oauth2/blob/master/clientcredentials/clientcredentials.go#L44) - Additional parameters that are sent to the token endpoint.
- [**scopes**](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) - **Optional** optional requested permissions associated for the client.
- [**timeout**](https://golang.org/src/net/http/client.go#L90) - **Optional** specifies the timeout on the underlying client to authorization server for fetching the tokens (initial and while refreshing).
Expand Down
102 changes: 102 additions & 0 deletions extension/oauth2clientauthextension/clientcredentialsconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package oauth2clientauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/oauth2clientauthextension"

import (
"context"
"fmt"
"os"
"strings"

"go.uber.org/multierr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)

// clientCredentialsConfig is a clientcredentials.Config wrapper to allow
// values read from files in the ClientID and ClientSecret fields.
//
// Values from files can be retrieved by populating the ClientIDFile or
// the ClientSecretFile fields with the path to the file.
//
// Priority: File > Raw value
//
// Example - Retrieve secret from file:
//
// cfg := clientCredentialsConfig{
// Config: clientcredentials.Config{
// ClientID: "clientId",
// ...
// },
// ClientSecretFile: "/path/to/client/secret",
// }
type clientCredentialsConfig struct {
clientcredentials.Config

ClientIDFile string
ClientSecretFile string
}

type clientCredentialsTokenSource struct {
ctx context.Context
config *clientCredentialsConfig
}

// clientCredentialsTokenSource implements TokenSource
var _ oauth2.TokenSource = (*clientCredentialsTokenSource)(nil)

func readCredentialsFile(path string) (string, error) {
f, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read credentials file %q: %w", path, err)
}

credential := strings.TrimSpace(string(f))
if credential == "" {
return "", fmt.Errorf("empty credentials file %q", path)
}
return credential, nil
}

func getActualValue(value, filepath string) (string, error) {
if len(filepath) > 0 {
return readCredentialsFile(filepath)
}

return value, nil
}

// createConfig creates a proper clientcredentials.Config with values retrieved
// from files, if the user has specified '*_file' values
func (c *clientCredentialsConfig) createConfig() (*clientcredentials.Config, error) {
clientID, err := getActualValue(c.ClientID, c.ClientIDFile)
if err != nil {
return nil, multierr.Combine(errNoClientIDProvided, err)
}

clientSecret, err := getActualValue(c.ClientSecret, c.ClientSecretFile)
if err != nil {
return nil, multierr.Combine(errNoClientSecretProvided, err)
}

return &clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: c.TokenURL,
Scopes: c.Scopes,
EndpointParams: c.EndpointParams,
}, nil
}

func (c *clientCredentialsConfig) TokenSource(ctx context.Context) oauth2.TokenSource {
return oauth2.ReuseTokenSource(nil, clientCredentialsTokenSource{ctx: ctx, config: c})
}

func (ts clientCredentialsTokenSource) Token() (*oauth2.Token, error) {
cfg, err := ts.config.createConfig()
if err != nil {
return nil, err
}
return cfg.TokenSource(ts.ctx).Token()
}
10 changes: 8 additions & 2 deletions extension/oauth2clientauthextension/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ type Config struct {
// See https://datatracker.ietf.org/doc/html/rfc6749#section-2.2
ClientID string `mapstructure:"client_id"`

// ClientIDFile is the file path to read the application's ID from.
ClientIDFile string `mapstructure:"client_id_file"`

// ClientSecret is the application's secret.
// See https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
ClientSecret configopaque.String `mapstructure:"client_secret"`

// ClientSecretFile is the file pathg to read the application's secret from.
ClientSecretFile string `mapstructure:"client_secret_file"`

// EndpointParams specifies additional parameters for requests to the token endpoint.
EndpointParams url.Values `mapstructure:"endpoint_params"`

Expand All @@ -54,10 +60,10 @@ var _ component.Config = (*Config)(nil)

// Validate checks if the extension configuration is valid
func (cfg *Config) Validate() error {
if cfg.ClientID == "" {
if cfg.ClientID == "" && cfg.ClientIDFile == "" {
return errNoClientIDProvided
}
if cfg.ClientSecret == "" {
if cfg.ClientSecret == "" && cfg.ClientSecretFile == "" {
return errNoClientSecretProvided
}
if cfg.TokenURL == "" {
Expand Down
22 changes: 13 additions & 9 deletions extension/oauth2clientauthextension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// clientAuthenticator provides implementation for providing client authentication using OAuth2 client credentials
// workflow for both gRPC and HTTP clients.
type clientAuthenticator struct {
clientCredentials *clientcredentials.Config
clientCredentials *clientCredentialsConfig
logger *zap.Logger
client *http.Client
}
Expand All @@ -36,10 +36,10 @@ var _ oauth2.TokenSource = (*errorWrappingTokenSource)(nil)
var errFailedToGetSecurityToken = fmt.Errorf("failed to get security token from token endpoint")

func newClientAuthenticator(cfg *Config, logger *zap.Logger) (*clientAuthenticator, error) {
if cfg.ClientID == "" {
if cfg.ClientID == "" && cfg.ClientIDFile == "" {
return nil, errNoClientIDProvided
}
if cfg.ClientSecret == "" {
if cfg.ClientSecret == "" && cfg.ClientSecretFile == "" {
return nil, errNoClientSecretProvided
}
if cfg.TokenURL == "" {
Expand All @@ -55,12 +55,16 @@ func newClientAuthenticator(cfg *Config, logger *zap.Logger) (*clientAuthenticat
transport.TLSClientConfig = tlsCfg

return &clientAuthenticator{
clientCredentials: &clientcredentials.Config{
ClientID: cfg.ClientID,
ClientSecret: string(cfg.ClientSecret),
TokenURL: cfg.TokenURL,
Scopes: cfg.Scopes,
EndpointParams: cfg.EndpointParams,
clientCredentials: &clientCredentialsConfig{
Config: clientcredentials.Config{
ClientID: cfg.ClientID,
ClientSecret: string(cfg.ClientSecret),
TokenURL: cfg.TokenURL,
Scopes: cfg.Scopes,
EndpointParams: cfg.EndpointParams,
},
ClientIDFile: cfg.ClientIDFile,
ClientSecretFile: cfg.ClientSecretFile,
},
logger: logger,
client: &http.Client{
Expand Down
93 changes: 93 additions & 0 deletions extension/oauth2clientauthextension/extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"go.opentelemetry.io/collector/config/configtls"
"go.uber.org/zap"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
grpcOAuth "google.golang.org/grpc/credentials/oauth"
)

Expand Down Expand Up @@ -134,6 +135,98 @@ func TestOAuthClientSettings(t *testing.T) {
}
}

func TestOAuthClientSettingsCredsConfig(t *testing.T) {
// test files for TLS testing
var (
testCredsFile = "testdata/test-cred.txt"
testCredsEmptyFile = "testdata/test-cred-empty.txt"
testCredsMissingFile = "testdata/test-cred-missing.txt"
)

tests := []struct {
name string
settings *Config
expectedClientConfig *clientcredentials.Config
shouldError bool
expectedError error
}{
{
name: "client_id_file",
settings: &Config{
ClientIDFile: testCredsFile,
ClientSecret: "testsecret",
TokenURL: "https://example.com/v1/token",
Scopes: []string{"resource.read"},
},
expectedClientConfig: &clientcredentials.Config{
ClientID: "testcreds",
ClientSecret: "testsecret",
},
shouldError: false,
expectedError: nil,
},
{
name: "client_secret_file",
settings: &Config{
ClientID: "testclientid",
ClientSecretFile: testCredsFile,
TokenURL: "https://example.com/v1/token",
Scopes: []string{"resource.read"},
},
expectedClientConfig: &clientcredentials.Config{
ClientID: "testclientid",
ClientSecret: "testcreds",
},
shouldError: false,
expectedError: nil,
},
{
name: "empty_client_creds_file",
settings: &Config{
ClientIDFile: testCredsEmptyFile,
ClientSecret: "testsecret",
TokenURL: "https://example.com/v1/token",
Scopes: []string{"resource.read"},
},
shouldError: true,
expectedError: errNoClientIDProvided,
},
{
name: "missing_client_creds_file",
settings: &Config{
ClientID: "testclientid",
ClientSecretFile: testCredsMissingFile,
TokenURL: "https://example.com/v1/token",
Scopes: []string{"resource.read"},
},
shouldError: true,
expectedError: errNoClientSecretProvided,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rc, _ := newClientAuthenticator(test.settings, zap.NewNop())
cfg, err := rc.clientCredentials.createConfig()
if test.shouldError {
assert.NotNil(t, err)
assert.ErrorAs(t, err, &test.expectedError)
return
}
assert.NoError(t, err)
assert.Equal(t, test.expectedClientConfig.ClientID, cfg.ClientID)
assert.Equal(t, test.expectedClientConfig.ClientSecret, cfg.ClientSecret)

// test tls settings
transport := rc.client.Transport.(*http.Transport)
tlsClientConfig := transport.TLSClientConfig
tlsTestSettingConfig, err := test.settings.TLSSetting.LoadTLSConfig()
assert.Nil(t, err)
assert.Equal(t, tlsClientConfig.Certificates, tlsTestSettingConfig.Certificates)
})
}
}

type testRoundTripper struct {
testString string
}
Expand Down
Empty file.
1 change: 1 addition & 0 deletions extension/oauth2clientauthextension/testdata/test-cred.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
testcreds

0 comments on commit df03442

Please sign in to comment.