Skip to content

Commit 9052284

Browse files
committed
config: add OAuth2 token_url_file
Signed-off-by: Devin Trejo <dtrejo@palantir.com>
1 parent 62e9d0f commit 9052284

4 files changed

Lines changed: 125 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### What's Changed
66

7+
* 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.
8+
79
## v0.69.0 / 2026-06-17
810

911
### Security / behavior changes

config/http_config.go

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ type OAuth2 struct {
273273
Claims map[string]any `yaml:"claims,omitempty" json:"claims,omitempty"`
274274
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
275275
TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"`
276+
TokenURLFile string `yaml:"token_url_file,omitempty" json:"token_url_file,omitempty"`
276277
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
277278
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
278279
ProxyConfig `yaml:",inline"`
@@ -302,6 +303,7 @@ func (o *OAuth2) SetDirectory(dir string) {
302303
return
303304
}
304305
o.ClientSecretFile = JoinDir(dir, o.ClientSecretFile)
306+
o.TokenURLFile = JoinDir(dir, o.TokenURLFile)
305307
o.TLSConfig.SetDirectory(dir)
306308
}
307309

@@ -438,8 +440,11 @@ func (c *HTTPClientConfig) Validate() error {
438440
if len(c.OAuth2.ClientID) == 0 {
439441
return errors.New("oauth2 client_id must be configured")
440442
}
441-
if len(c.OAuth2.TokenURL) == 0 {
442-
return errors.New("oauth2 token_url must be configured")
443+
if len(c.OAuth2.TokenURL) == 0 && len(c.OAuth2.TokenURLFile) == 0 {
444+
return errors.New("one of oauth2 token_url or token_url_file must be configured")
445+
}
446+
if len(c.OAuth2.TokenURL) > 0 && len(c.OAuth2.TokenURLFile) > 0 {
447+
return errors.New("at most one of oauth2 token_url & token_url_file must be configured")
443448
}
444449
if c.OAuth2.GrantType == grantTypeJWTBearer {
445450
if nonZeroCount(len(c.OAuth2.ClientCertificateKey) > 0, len(c.OAuth2.ClientCertificateKeyFile) > 0, len(c.OAuth2.ClientCertificateKeyRef) > 0) > 1 {
@@ -940,13 +945,15 @@ func (rt *basicAuthRoundTripper) CloseIdleConnections() {
940945
}
941946

942947
type oauth2RoundTripper struct {
943-
mtx sync.RWMutex
944-
lastRT *oauth2.Transport
945-
lastSecret string
948+
mtx sync.RWMutex
949+
lastRT *oauth2.Transport
950+
lastSecret string
951+
lastTokenURL string
946952

947953
// Required for interaction with Oauth2 server.
948954
config *OAuth2
949955
oauthCredential SecretReader
956+
tokenURL SecretReader
950957
opts *httpClientOptions
951958
client *http.Client
952959
}
@@ -966,20 +973,29 @@ func NewOAuth2RoundTripper(oauthCredential SecretReader, config *OAuth2, next ht
966973
opt.applyToHTTPClientOptions(&opts)
967974
}
968975

976+
var tokenURL SecretReader
977+
switch {
978+
case config.TokenURLFile != "":
979+
tokenURL = NewFileSecret(config.TokenURLFile)
980+
default:
981+
tokenURL = NewInlineSecret(config.TokenURL)
982+
}
983+
969984
return &oauth2RoundTripper{
970985
config: config,
971986
// A correct tokenSource will be added later on.
972987
lastRT: &oauth2.Transport{Base: next},
973988
opts: &opts,
974989
oauthCredential: oauthCredential,
990+
tokenURL: tokenURL,
975991
}
976992
}
977993

978994
type oauth2TokenSourceConfig interface {
979995
TokenSource(ctx context.Context) oauth2.TokenSource
980996
}
981997

982-
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential string) (client *http.Client, source oauth2.TokenSource, err error) {
998+
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential, tokenURL string) (client *http.Client, source oauth2.TokenSource, err error) {
983999
tlsConfig, err := NewTLSConfig(&rt.config.TLSConfig, WithSecretManager(rt.opts.secretManager))
9841000
if err != nil {
9851001
return nil, nil, err
@@ -1045,7 +1061,7 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCred
10451061
PrivateKey: []byte(clientCredential),
10461062
PrivateKeyID: rt.config.ClientCertificateKeyID,
10471063
Scopes: rt.config.Scopes,
1048-
TokenURL: rt.config.TokenURL,
1064+
TokenURL: tokenURL,
10491065
SigningAlgorithm: sig,
10501066
Iss: iss,
10511067
Subject: rt.config.ClientID,
@@ -1058,7 +1074,7 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCred
10581074
ClientID: rt.config.ClientID,
10591075
ClientSecret: clientCredential,
10601076
Scopes: rt.config.Scopes,
1061-
TokenURL: rt.config.TokenURL,
1077+
TokenURL: tokenURL,
10621078
EndpointParams: mapToValues(rt.config.EndpointParams),
10631079
}
10641080
}
@@ -1082,6 +1098,7 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
10821098

10831099
var (
10841100
secret string
1101+
tokenURL string
10851102
needsInit bool
10861103
)
10871104

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

10941111
rt.mtx.RLock()
10951112
secret = rt.lastSecret
1113+
tokenURL = rt.lastTokenURL
10961114
needsInit = rt.lastRT.Source == nil
10971115
rt.mtx.RUnlock()
10981116

10991117
// Fetch the secret if it's our first run or always if the secret can change.
1118+
newSecret := secret
11001119
if !rt.oauthCredential.Immutable() || needsInit {
1101-
newSecret, err := rt.oauthCredential.Fetch(req.Context())
1120+
var err error
1121+
newSecret, err = rt.oauthCredential.Fetch(req.Context())
11021122
if err != nil {
11031123
return nil, fmt.Errorf("unable to read oauth2 client secret: %w", err)
11041124
}
1105-
if newSecret != secret || needsInit {
1106-
// Secret changed or it's a first run. Rebuilt oauth2 setup.
1107-
client, source, err := rt.newOauth2TokenSource(req, newSecret)
1108-
if err != nil {
1109-
return nil, err
1110-
}
1125+
}
11111126

1112-
rt.mtx.Lock()
1113-
rt.lastSecret = newSecret
1114-
rt.lastRT.Source = source
1115-
if rt.client != nil {
1116-
rt.client.CloseIdleConnections()
1117-
}
1118-
rt.client = client
1119-
rt.mtx.Unlock()
1127+
// Fetch the token URL if it's our first run or always if it can change (file-backed).
1128+
newTokenURL := tokenURL
1129+
if !rt.tokenURL.Immutable() || needsInit {
1130+
var err error
1131+
newTokenURL, err = rt.tokenURL.Fetch(req.Context())
1132+
if err != nil {
1133+
return nil, fmt.Errorf("unable to read oauth2 token_url: %w", err)
1134+
}
1135+
}
1136+
1137+
if newSecret != secret || newTokenURL != tokenURL || needsInit {
1138+
// Secret or token URL changed or it's a first run. Rebuild oauth2 setup.
1139+
client, source, err := rt.newOauth2TokenSource(req, newSecret, newTokenURL)
1140+
if err != nil {
1141+
return nil, err
1142+
}
1143+
1144+
rt.mtx.Lock()
1145+
rt.lastSecret = newSecret
1146+
rt.lastTokenURL = newTokenURL
1147+
rt.lastRT.Source = source
1148+
if rt.client != nil {
1149+
rt.client.CloseIdleConnections()
11201150
}
1151+
rt.client = client
1152+
rt.mtx.Unlock()
11211153
}
11221154

11231155
rt.mtx.RLock()

config/http_config_test.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ var invalidHTTPClientConfigs = []struct {
123123
},
124124
{
125125
httpClientConfigFile: "testdata/http.conf.oauth2-no-token-url.bad.yaml",
126-
errMsg: "oauth2 token_url must be configured",
126+
errMsg: "one of oauth2 token_url or token_url_file must be configured",
127+
},
128+
{
129+
httpClientConfigFile: "testdata/http.conf.oauth2-token-url-and-file-set.bad.yml",
130+
errMsg: "at most one of oauth2 token_url & token_url_file must be configured",
127131
},
128132
{
129133
httpClientConfigFile: "testdata/http.conf.proxy-from-env.bad.yaml",
@@ -2094,6 +2098,64 @@ endpoint_params:
20942098
require.Equalf(t, "Bearer 12345", authorization, "Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
20952099
}
20962100

2101+
func TestOAuth2WithTokenURLFile(t *testing.T) {
2102+
expectedAuth := new(string)
2103+
ts := newTestOAuthServer(t, func(t testing.TB, auth string) {
2104+
require.Equalf(t, *expectedAuth, auth, "bad auth, expected %s, got %s", *expectedAuth, auth)
2105+
})
2106+
defer ts.close()
2107+
2108+
tokenURLFile, err := os.CreateTemp("", "oauth2_token_url")
2109+
require.NoError(t, err)
2110+
defer os.Remove(tokenURLFile.Name())
2111+
2112+
_, err = tokenURLFile.WriteString(ts.tokenURL())
2113+
require.NoError(t, err)
2114+
require.NoError(t, tokenURLFile.Close())
2115+
2116+
yamlConfig := fmt.Sprintf(`
2117+
client_id: 1
2118+
client_secret: 2
2119+
scopes:
2120+
- A
2121+
- B
2122+
token_url_file: %s
2123+
endpoint_params:
2124+
hi: hello
2125+
`, tokenURLFile.Name())
2126+
expectedConfig := OAuth2{
2127+
ClientID: "1",
2128+
ClientSecret: "2",
2129+
Scopes: []string{"A", "B"},
2130+
EndpointParams: map[string]string{"hi": "hello"},
2131+
TokenURLFile: tokenURLFile.Name(),
2132+
}
2133+
2134+
var unmarshalledConfig OAuth2
2135+
err = yaml.Unmarshal([]byte(yamlConfig), &unmarshalledConfig)
2136+
require.NoErrorf(t, err, "Expected no error unmarshalling yaml, got %v", err)
2137+
require.Truef(t, reflect.DeepEqual(unmarshalledConfig, expectedConfig), "Got unmarshalled config %v, expected %v", unmarshalledConfig, expectedConfig)
2138+
2139+
secret := NewInlineSecret(string(expectedConfig.ClientSecret))
2140+
rt := NewOAuth2RoundTripper(secret, &expectedConfig, http.DefaultTransport)
2141+
2142+
client := http.Client{
2143+
Transport: rt,
2144+
}
2145+
2146+
*expectedAuth = "Basic MToy"
2147+
resp, err := client.Get(ts.url())
2148+
require.NoError(t, err)
2149+
2150+
authorization := resp.Request.Header.Get("Authorization")
2151+
require.Equalf(t, "Bearer 12345", authorization, "Expected authorization header to be 'Bearer 12345', got '%s'", authorization)
2152+
2153+
// A follow-up request with unchanged file contents should not trigger a token
2154+
// endpoint call (the underlying oauth2.ReuseTokenSource covers the caching side).
2155+
_, err = client.Get(ts.url())
2156+
require.NoError(t, err)
2157+
}
2158+
20972159
func TestOAuth2WithJWTAuth(t *testing.T) {
20982160
ts := newTestOAuthServer(t, func(t testing.TB, auth string) {
20992161
t.Helper()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
oauth2:
2+
client_id: "myclient"
3+
client_secret: "mysecret"
4+
token_url: "http://auth"
5+
token_url_file: "/etc/token_url"

0 commit comments

Comments
 (0)