Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### What's Changed

* config: support `token_url_file` in the OAuth2 config, allowing the token URL to be loaded from a file (useful for Helm-style deployments where the URL is only known at deploy time). Complements the existing `token_url` field; exactly one of the two must be set. See prometheus/alertmanager#4759.

## v0.69.0 / 2026-06-17

### Security / behavior changes
Expand Down
78 changes: 55 additions & 23 deletions config/http_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ type OAuth2 struct {
Claims map[string]any `yaml:"claims,omitempty" json:"claims,omitempty"`
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"`
TokenURLFile string `yaml:"token_url_file,omitempty" json:"token_url_file,omitempty"`
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
ProxyConfig `yaml:",inline"`
Expand Down Expand Up @@ -302,6 +303,7 @@ func (o *OAuth2) SetDirectory(dir string) {
return
}
o.ClientSecretFile = JoinDir(dir, o.ClientSecretFile)
o.TokenURLFile = JoinDir(dir, o.TokenURLFile)
o.TLSConfig.SetDirectory(dir)
}

Expand Down Expand Up @@ -438,8 +440,11 @@ func (c *HTTPClientConfig) Validate() error {
if len(c.OAuth2.ClientID) == 0 {
return errors.New("oauth2 client_id must be configured")
}
if len(c.OAuth2.TokenURL) == 0 {
return errors.New("oauth2 token_url must be configured")
if len(c.OAuth2.TokenURL) == 0 && len(c.OAuth2.TokenURLFile) == 0 {
return errors.New("one of oauth2 token_url or token_url_file must be configured")
}
if len(c.OAuth2.TokenURL) > 0 && len(c.OAuth2.TokenURLFile) > 0 {
return errors.New("at most one of oauth2 token_url & token_url_file must be configured")
}
if c.OAuth2.GrantType == grantTypeJWTBearer {
if nonZeroCount(len(c.OAuth2.ClientCertificateKey) > 0, len(c.OAuth2.ClientCertificateKeyFile) > 0, len(c.OAuth2.ClientCertificateKeyRef) > 0) > 1 {
Expand Down Expand Up @@ -940,13 +945,15 @@ func (rt *basicAuthRoundTripper) CloseIdleConnections() {
}

type oauth2RoundTripper struct {
mtx sync.RWMutex
lastRT *oauth2.Transport
lastSecret string
mtx sync.RWMutex
lastRT *oauth2.Transport
lastSecret string
lastTokenURL string

// Required for interaction with Oauth2 server.
config *OAuth2
oauthCredential SecretReader
tokenURL SecretReader
opts *httpClientOptions
client *http.Client
}
Expand All @@ -966,20 +973,29 @@ func NewOAuth2RoundTripper(oauthCredential SecretReader, config *OAuth2, next ht
opt.applyToHTTPClientOptions(&opts)
}

var tokenURL SecretReader
switch {
case config.TokenURLFile != "":
tokenURL = NewFileSecret(config.TokenURLFile)
default:
tokenURL = NewInlineSecret(config.TokenURL)
}

return &oauth2RoundTripper{
config: config,
// A correct tokenSource will be added later on.
lastRT: &oauth2.Transport{Base: next},
opts: &opts,
oauthCredential: oauthCredential,
tokenURL: tokenURL,
}
}

type oauth2TokenSourceConfig interface {
TokenSource(ctx context.Context) oauth2.TokenSource
}

func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential string) (client *http.Client, source oauth2.TokenSource, err error) {
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential, tokenURL string) (client *http.Client, source oauth2.TokenSource, err error) {
tlsConfig, err := NewTLSConfig(&rt.config.TLSConfig, WithSecretManager(rt.opts.secretManager))
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -1045,7 +1061,7 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCred
PrivateKey: []byte(clientCredential),
PrivateKeyID: rt.config.ClientCertificateKeyID,
Scopes: rt.config.Scopes,
TokenURL: rt.config.TokenURL,
TokenURL: tokenURL,
SigningAlgorithm: sig,
Iss: iss,
Subject: rt.config.ClientID,
Expand All @@ -1058,7 +1074,7 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCred
ClientID: rt.config.ClientID,
ClientSecret: clientCredential,
Scopes: rt.config.Scopes,
TokenURL: rt.config.TokenURL,
TokenURL: tokenURL,
EndpointParams: mapToValues(rt.config.EndpointParams),
}
}
Expand All @@ -1082,6 +1098,7 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro

var (
secret string
tokenURL string
needsInit bool
)

Expand All @@ -1093,31 +1110,46 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro

rt.mtx.RLock()
secret = rt.lastSecret
tokenURL = rt.lastTokenURL
needsInit = rt.lastRT.Source == nil
rt.mtx.RUnlock()

// Fetch the secret if it's our first run or always if the secret can change.
newSecret := secret
if !rt.oauthCredential.Immutable() || needsInit {
newSecret, err := rt.oauthCredential.Fetch(req.Context())
var err error
newSecret, err = rt.oauthCredential.Fetch(req.Context())
if err != nil {
return nil, fmt.Errorf("unable to read oauth2 client secret: %w", err)
}
if newSecret != secret || needsInit {
// Secret changed or it's a first run. Rebuilt oauth2 setup.
client, source, err := rt.newOauth2TokenSource(req, newSecret)
if err != nil {
return nil, err
}
}

rt.mtx.Lock()
rt.lastSecret = newSecret
rt.lastRT.Source = source
if rt.client != nil {
rt.client.CloseIdleConnections()
}
rt.client = client
rt.mtx.Unlock()
// Fetch the token URL if it's our first run or always if it can change (file-backed).
newTokenURL := tokenURL
if !rt.tokenURL.Immutable() || needsInit {
var err error
newTokenURL, err = rt.tokenURL.Fetch(req.Context())
if err != nil {
return nil, fmt.Errorf("unable to read oauth2 token_url: %w", err)
}
}

if newSecret != secret || newTokenURL != tokenURL || needsInit {
// Secret or token URL changed or it's a first run. Rebuild oauth2 setup.
client, source, err := rt.newOauth2TokenSource(req, newSecret, newTokenURL)
if err != nil {
return nil, err
}

rt.mtx.Lock()
rt.lastSecret = newSecret
rt.lastTokenURL = newTokenURL
rt.lastRT.Source = source
if rt.client != nil {
rt.client.CloseIdleConnections()
}
rt.client = client
rt.mtx.Unlock()
}

rt.mtx.RLock()
Expand Down
64 changes: 63 additions & 1 deletion config/http_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ var invalidHTTPClientConfigs = []struct {
},
{
httpClientConfigFile: "testdata/http.conf.oauth2-no-token-url.bad.yaml",
errMsg: "oauth2 token_url must be configured",
errMsg: "one of oauth2 token_url or token_url_file must be configured",
},
{
httpClientConfigFile: "testdata/http.conf.oauth2-token-url-and-file-set.bad.yml",
errMsg: "at most one of oauth2 token_url & token_url_file must be configured",
},
{
httpClientConfigFile: "testdata/http.conf.proxy-from-env.bad.yaml",
Expand Down Expand Up @@ -2094,6 +2098,64 @@ endpoint_params:
require.Equalf(t, "Bearer 12345", authorization, "Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
}

func TestOAuth2WithTokenURLFile(t *testing.T) {
expectedAuth := new(string)
ts := newTestOAuthServer(t, func(t testing.TB, auth string) {
require.Equalf(t, *expectedAuth, auth, "bad auth, expected %s, got %s", *expectedAuth, auth)
})
defer ts.close()

tokenURLFile, err := os.CreateTemp("", "oauth2_token_url")
require.NoError(t, err)
defer os.Remove(tokenURLFile.Name())

_, err = tokenURLFile.WriteString(ts.tokenURL())
require.NoError(t, err)
require.NoError(t, tokenURLFile.Close())

yamlConfig := fmt.Sprintf(`
client_id: 1
client_secret: 2
scopes:
- A
- B
token_url_file: %s
endpoint_params:
hi: hello
`, tokenURLFile.Name())
expectedConfig := OAuth2{
ClientID: "1",
ClientSecret: "2",
Scopes: []string{"A", "B"},
EndpointParams: map[string]string{"hi": "hello"},
TokenURLFile: tokenURLFile.Name(),
}

var unmarshalledConfig OAuth2
err = yaml.Unmarshal([]byte(yamlConfig), &unmarshalledConfig)
require.NoErrorf(t, err, "Expected no error unmarshalling yaml, got %v", err)
require.Truef(t, reflect.DeepEqual(unmarshalledConfig, expectedConfig), "Got unmarshalled config %v, expected %v", unmarshalledConfig, expectedConfig)

secret := NewInlineSecret(string(expectedConfig.ClientSecret))
rt := NewOAuth2RoundTripper(secret, &expectedConfig, http.DefaultTransport)

client := http.Client{
Transport: rt,
}

*expectedAuth = "Basic MToy"
resp, err := client.Get(ts.url())
require.NoError(t, err)

authorization := resp.Request.Header.Get("Authorization")
require.Equalf(t, "Bearer 12345", authorization, "Expected authorization header to be 'Bearer 12345', got '%s'", authorization)

// A follow-up request with unchanged file contents should not trigger a token
// endpoint call (the underlying oauth2.ReuseTokenSource covers the caching side).
_, err = client.Get(ts.url())
require.NoError(t, err)
}

func TestOAuth2WithJWTAuth(t *testing.T) {
ts := newTestOAuthServer(t, func(t testing.TB, auth string) {
t.Helper()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
oauth2:
client_id: "myclient"
client_secret: "mysecret"
token_url: "http://auth"
token_url_file: "/etc/token_url"
Loading