Skip to content

Commit df03442

Browse files
authored
[extension/oauth2clientauth] Enable dynamically reading ClientID and 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
1 parent 75e29ce commit df03442

File tree

8 files changed

+252
-11
lines changed

8 files changed

+252
-11
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: oauth2clientauthextension
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Enable dynamically reading ClientID and ClientSecret from files
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [26117]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext: |
19+
- 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.
20+
- 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.
21+
22+
# If your change doesn't affect end users or the exported elements of any package,
23+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
24+
# Optional: The change log or logs in which this entry should be included.
25+
# e.g. '[user]' or '[user, api]'
26+
# Include 'user' if the change is relevant to end users.
27+
# Include 'api' if there is a change to a library API.
28+
# Default: '[user]'
29+
change_logs: [user, api]

extension/oauth2clientauthextension/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ Following are the configuration fields
7474
7575
- [**token_url**](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2) - The resource server's token endpoint URLs.
7676
- [**client_id**](https://datatracker.ietf.org/doc/html/rfc6749#section-2.2) - The client identifier issued to the client.
77+
- **client_id_file** - The file path to retrieve the client identifier issued to the client.
78+
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 -->
79+
This setting takes precedence over `client_id`.
7780
- [**client_secret**](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1) - The secret string associated with above identifier.
81+
- **client_secret_file** - The file path to retrieve the secret string associated with above identifier.
82+
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 -->
83+
This setting takes precedence over `client_secret`.
7884
- [**endpoint_params**](https://github.com/golang/oauth2/blob/master/clientcredentials/clientcredentials.go#L44) - Additional parameters that are sent to the token endpoint.
7985
- [**scopes**](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) - **Optional** optional requested permissions associated for the client.
8086
- [**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).
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package oauth2clientauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/oauth2clientauthextension"
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"os"
10+
"strings"
11+
12+
"go.uber.org/multierr"
13+
"golang.org/x/oauth2"
14+
"golang.org/x/oauth2/clientcredentials"
15+
)
16+
17+
// clientCredentialsConfig is a clientcredentials.Config wrapper to allow
18+
// values read from files in the ClientID and ClientSecret fields.
19+
//
20+
// Values from files can be retrieved by populating the ClientIDFile or
21+
// the ClientSecretFile fields with the path to the file.
22+
//
23+
// Priority: File > Raw value
24+
//
25+
// Example - Retrieve secret from file:
26+
//
27+
// cfg := clientCredentialsConfig{
28+
// Config: clientcredentials.Config{
29+
// ClientID: "clientId",
30+
// ...
31+
// },
32+
// ClientSecretFile: "/path/to/client/secret",
33+
// }
34+
type clientCredentialsConfig struct {
35+
clientcredentials.Config
36+
37+
ClientIDFile string
38+
ClientSecretFile string
39+
}
40+
41+
type clientCredentialsTokenSource struct {
42+
ctx context.Context
43+
config *clientCredentialsConfig
44+
}
45+
46+
// clientCredentialsTokenSource implements TokenSource
47+
var _ oauth2.TokenSource = (*clientCredentialsTokenSource)(nil)
48+
49+
func readCredentialsFile(path string) (string, error) {
50+
f, err := os.ReadFile(path)
51+
if err != nil {
52+
return "", fmt.Errorf("failed to read credentials file %q: %w", path, err)
53+
}
54+
55+
credential := strings.TrimSpace(string(f))
56+
if credential == "" {
57+
return "", fmt.Errorf("empty credentials file %q", path)
58+
}
59+
return credential, nil
60+
}
61+
62+
func getActualValue(value, filepath string) (string, error) {
63+
if len(filepath) > 0 {
64+
return readCredentialsFile(filepath)
65+
}
66+
67+
return value, nil
68+
}
69+
70+
// createConfig creates a proper clientcredentials.Config with values retrieved
71+
// from files, if the user has specified '*_file' values
72+
func (c *clientCredentialsConfig) createConfig() (*clientcredentials.Config, error) {
73+
clientID, err := getActualValue(c.ClientID, c.ClientIDFile)
74+
if err != nil {
75+
return nil, multierr.Combine(errNoClientIDProvided, err)
76+
}
77+
78+
clientSecret, err := getActualValue(c.ClientSecret, c.ClientSecretFile)
79+
if err != nil {
80+
return nil, multierr.Combine(errNoClientSecretProvided, err)
81+
}
82+
83+
return &clientcredentials.Config{
84+
ClientID: clientID,
85+
ClientSecret: clientSecret,
86+
TokenURL: c.TokenURL,
87+
Scopes: c.Scopes,
88+
EndpointParams: c.EndpointParams,
89+
}, nil
90+
}
91+
92+
func (c *clientCredentialsConfig) TokenSource(ctx context.Context) oauth2.TokenSource {
93+
return oauth2.ReuseTokenSource(nil, clientCredentialsTokenSource{ctx: ctx, config: c})
94+
}
95+
96+
func (ts clientCredentialsTokenSource) Token() (*oauth2.Token, error) {
97+
cfg, err := ts.config.createConfig()
98+
if err != nil {
99+
return nil, err
100+
}
101+
return cfg.TokenSource(ts.ctx).Token()
102+
}

extension/oauth2clientauthextension/config.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ type Config struct {
2626
// See https://datatracker.ietf.org/doc/html/rfc6749#section-2.2
2727
ClientID string `mapstructure:"client_id"`
2828

29+
// ClientIDFile is the file path to read the application's ID from.
30+
ClientIDFile string `mapstructure:"client_id_file"`
31+
2932
// ClientSecret is the application's secret.
3033
// See https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
3134
ClientSecret configopaque.String `mapstructure:"client_secret"`
3235

36+
// ClientSecretFile is the file pathg to read the application's secret from.
37+
ClientSecretFile string `mapstructure:"client_secret_file"`
38+
3339
// EndpointParams specifies additional parameters for requests to the token endpoint.
3440
EndpointParams url.Values `mapstructure:"endpoint_params"`
3541

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

5561
// Validate checks if the extension configuration is valid
5662
func (cfg *Config) Validate() error {
57-
if cfg.ClientID == "" {
63+
if cfg.ClientID == "" && cfg.ClientIDFile == "" {
5864
return errNoClientIDProvided
5965
}
60-
if cfg.ClientSecret == "" {
66+
if cfg.ClientSecret == "" && cfg.ClientSecretFile == "" {
6167
return errNoClientSecretProvided
6268
}
6369
if cfg.TokenURL == "" {

extension/oauth2clientauthextension/extension.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
// clientAuthenticator provides implementation for providing client authentication using OAuth2 client credentials
2020
// workflow for both gRPC and HTTP clients.
2121
type clientAuthenticator struct {
22-
clientCredentials *clientcredentials.Config
22+
clientCredentials *clientCredentialsConfig
2323
logger *zap.Logger
2424
client *http.Client
2525
}
@@ -36,10 +36,10 @@ var _ oauth2.TokenSource = (*errorWrappingTokenSource)(nil)
3636
var errFailedToGetSecurityToken = fmt.Errorf("failed to get security token from token endpoint")
3737

3838
func newClientAuthenticator(cfg *Config, logger *zap.Logger) (*clientAuthenticator, error) {
39-
if cfg.ClientID == "" {
39+
if cfg.ClientID == "" && cfg.ClientIDFile == "" {
4040
return nil, errNoClientIDProvided
4141
}
42-
if cfg.ClientSecret == "" {
42+
if cfg.ClientSecret == "" && cfg.ClientSecretFile == "" {
4343
return nil, errNoClientSecretProvided
4444
}
4545
if cfg.TokenURL == "" {
@@ -55,12 +55,16 @@ func newClientAuthenticator(cfg *Config, logger *zap.Logger) (*clientAuthenticat
5555
transport.TLSClientConfig = tlsCfg
5656

5757
return &clientAuthenticator{
58-
clientCredentials: &clientcredentials.Config{
59-
ClientID: cfg.ClientID,
60-
ClientSecret: string(cfg.ClientSecret),
61-
TokenURL: cfg.TokenURL,
62-
Scopes: cfg.Scopes,
63-
EndpointParams: cfg.EndpointParams,
58+
clientCredentials: &clientCredentialsConfig{
59+
Config: clientcredentials.Config{
60+
ClientID: cfg.ClientID,
61+
ClientSecret: string(cfg.ClientSecret),
62+
TokenURL: cfg.TokenURL,
63+
Scopes: cfg.Scopes,
64+
EndpointParams: cfg.EndpointParams,
65+
},
66+
ClientIDFile: cfg.ClientIDFile,
67+
ClientSecretFile: cfg.ClientSecretFile,
6468
},
6569
logger: logger,
6670
client: &http.Client{

extension/oauth2clientauthextension/extension_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"go.opentelemetry.io/collector/config/configtls"
1717
"go.uber.org/zap"
1818
"golang.org/x/oauth2"
19+
"golang.org/x/oauth2/clientcredentials"
1920
grpcOAuth "google.golang.org/grpc/credentials/oauth"
2021
)
2122

@@ -134,6 +135,98 @@ func TestOAuthClientSettings(t *testing.T) {
134135
}
135136
}
136137

138+
func TestOAuthClientSettingsCredsConfig(t *testing.T) {
139+
// test files for TLS testing
140+
var (
141+
testCredsFile = "testdata/test-cred.txt"
142+
testCredsEmptyFile = "testdata/test-cred-empty.txt"
143+
testCredsMissingFile = "testdata/test-cred-missing.txt"
144+
)
145+
146+
tests := []struct {
147+
name string
148+
settings *Config
149+
expectedClientConfig *clientcredentials.Config
150+
shouldError bool
151+
expectedError error
152+
}{
153+
{
154+
name: "client_id_file",
155+
settings: &Config{
156+
ClientIDFile: testCredsFile,
157+
ClientSecret: "testsecret",
158+
TokenURL: "https://example.com/v1/token",
159+
Scopes: []string{"resource.read"},
160+
},
161+
expectedClientConfig: &clientcredentials.Config{
162+
ClientID: "testcreds",
163+
ClientSecret: "testsecret",
164+
},
165+
shouldError: false,
166+
expectedError: nil,
167+
},
168+
{
169+
name: "client_secret_file",
170+
settings: &Config{
171+
ClientID: "testclientid",
172+
ClientSecretFile: testCredsFile,
173+
TokenURL: "https://example.com/v1/token",
174+
Scopes: []string{"resource.read"},
175+
},
176+
expectedClientConfig: &clientcredentials.Config{
177+
ClientID: "testclientid",
178+
ClientSecret: "testcreds",
179+
},
180+
shouldError: false,
181+
expectedError: nil,
182+
},
183+
{
184+
name: "empty_client_creds_file",
185+
settings: &Config{
186+
ClientIDFile: testCredsEmptyFile,
187+
ClientSecret: "testsecret",
188+
TokenURL: "https://example.com/v1/token",
189+
Scopes: []string{"resource.read"},
190+
},
191+
shouldError: true,
192+
expectedError: errNoClientIDProvided,
193+
},
194+
{
195+
name: "missing_client_creds_file",
196+
settings: &Config{
197+
ClientID: "testclientid",
198+
ClientSecretFile: testCredsMissingFile,
199+
TokenURL: "https://example.com/v1/token",
200+
Scopes: []string{"resource.read"},
201+
},
202+
shouldError: true,
203+
expectedError: errNoClientSecretProvided,
204+
},
205+
}
206+
207+
for _, test := range tests {
208+
t.Run(test.name, func(t *testing.T) {
209+
rc, _ := newClientAuthenticator(test.settings, zap.NewNop())
210+
cfg, err := rc.clientCredentials.createConfig()
211+
if test.shouldError {
212+
assert.NotNil(t, err)
213+
assert.ErrorAs(t, err, &test.expectedError)
214+
return
215+
}
216+
assert.NoError(t, err)
217+
assert.Equal(t, test.expectedClientConfig.ClientID, cfg.ClientID)
218+
assert.Equal(t, test.expectedClientConfig.ClientSecret, cfg.ClientSecret)
219+
220+
// test tls settings
221+
transport := rc.client.Transport.(*http.Transport)
222+
tlsClientConfig := transport.TLSClientConfig
223+
tlsTestSettingConfig, err := test.settings.TLSSetting.LoadTLSConfig()
224+
assert.Nil(t, err)
225+
assert.Equal(t, tlsClientConfig.Certificates, tlsTestSettingConfig.Certificates)
226+
})
227+
}
228+
}
229+
137230
type testRoundTripper struct {
138231
testString string
139232
}

extension/oauth2clientauthextension/testdata/test-cred-empty.txt

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
testcreds

0 commit comments

Comments
 (0)