From f4c0aea59fa97a7627730e65cb2e625ec9fc45cf Mon Sep 17 00:00:00 2001 From: Jorge Turrado Ferrero Date: Fri, 7 Nov 2025 15:02:01 +0100 Subject: [PATCH 1/5] Support JWT Profile for Authorization Grant (RFC 7523 3.1) (#862) --- config/http_config.go | 156 +++++++++++--- config/http_config_test.go | 119 +++++++++-- config/oauth_assertion.go | 194 ++++++++++++++++++ ...nf.oauth2-certificate-and-file-set.bad.yml | 6 + go.mod | 2 + go.sum | 4 + 6 files changed, 437 insertions(+), 44 deletions(-) create mode 100644 config/oauth_assertion.go create mode 100644 config/testdata/http.conf.oauth2-certificate-and-file-set.bad.yml diff --git a/config/http_config.go b/config/http_config.go index dd9673307..93fff55ab 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -27,11 +27,13 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "sync" "time" - conntrack "github.com/mwitkow/go-conntrack" + "github.com/golang-jwt/jwt/v5" + "github.com/mwitkow/go-conntrack" "go.yaml.in/yaml/v2" "golang.org/x/net/http/httpproxy" "golang.org/x/net/http2" @@ -39,6 +41,10 @@ import ( "golang.org/x/oauth2/clientcredentials" ) +const ( + grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" +) + var ( // DefaultHTTPClientConfig is the default HTTP client configuration. DefaultHTTPClientConfig = HTTPClientConfig{ @@ -237,12 +243,38 @@ type OAuth2 struct { ClientSecretFile string `yaml:"client_secret_file" json:"client_secret_file"` // ClientSecretRef is the name of the secret within the secret manager to use as the client // secret. - ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"` - Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` - TokenURL string `yaml:"token_url" json:"token_url"` - EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"` - TLSConfig TLSConfig `yaml:"tls_config,omitempty"` - ProxyConfig `yaml:",inline"` + ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"` + ClientCertificateKeyID string `yaml:"client_certificate_key_id" json:"client_certificate_key_id"` + ClientCertificateKey Secret `yaml:"client_certificate_key" json:"client_certificate_key"` + ClientCertificateKeyFile string `yaml:"client_certificate_key_file" json:"client_certificate_key_file"` + // ClientCertificateKeyRef is the name of the secret within the secret manager to use as the client + // secret. + ClientCertificateKeyRef string `yaml:"client_certificate_key_ref" json:"client_certificate_key_ref"` + // GrantType is the OAuth2 grant type to use. It can be one of + // "client_credentials" or "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523). + // Default value is "client_credentials" + GrantType string `yaml:"grant_type" json:"grant_type"` + // SignatureAlgorithm is the RSA algorithm used to sign JWT token. Only used if + // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". + // Default value is RS256 and valid values RS256, RS384, RS512 + SignatureAlgorithm string `yaml:"signature_algorithm,omitempty" json:"signature_algorithm,omitempty"` + // Iss is the OAuth client identifier used when communicating with + // the configured OAuth provider. Default value is client_id. Only used if + // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". + Iss string `yaml:"iss,omitempty" json:"iss,omitempty"` + // Audience optionally specifies the intended audience of the + // request. If empty, the value of TokenURL is used as the + // intended audience. Only used if + // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". + Audience string `yaml:"audience,omitempty" json:"audience,omitempty"` + // Claims is a map of claims to be added to the JWT token. Only used if + // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". + Claims map[string]interface{} `yaml:"claims,omitempty" json:"claims,omitempty"` + Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` + TokenURL string `yaml:"token_url" json:"token_url"` + EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"` + TLSConfig TLSConfig `yaml:"tls_config,omitempty"` + ProxyConfig `yaml:",inline"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. @@ -408,8 +440,15 @@ func (c *HTTPClientConfig) Validate() error { if len(c.OAuth2.TokenURL) == 0 { return errors.New("oauth2 token_url must be configured") } - if nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 1 { - return errors.New("at most one of oauth2 client_secret, client_secret_file & client_secret_ref 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 { + return errors.New("at most one of oauth2 client_certificate_key, client_certificate_key_file & client_certificate_key_ref must be configured using grant-type=urn:ietf:params:oauth:grant-type:jwt-bearer") + } + if c.OAuth2.SignatureAlgorithm != "" && !slices.Contains(validSignatureAlgorithm, c.OAuth2.SignatureAlgorithm) { + return errors.New("valid signature algorithms are RS256, RS384 and RS512") + } + } else if nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 1 { + return errors.New("at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured using grant-type=client_credentials") } } if err := c.ProxyConfig.Validate(); err != nil { @@ -668,11 +707,23 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon } if cfg.OAuth2 != nil { - clientSecret, err := toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef) - if err != nil { - return nil, fmt.Errorf("unable to use client secret: %w", err) + var ( + oauthCredential SecretReader + err error + ) + + if cfg.OAuth2.GrantType == grantTypeJWTBearer { + oauthCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientCertificateKey, cfg.OAuth2.ClientCertificateKeyFile, cfg.OAuth2.ClientCertificateKeyRef) + if err != nil { + return nil, fmt.Errorf("unable to use client certificate: %w", err) + } + } else { + oauthCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef) + if err != nil { + return nil, fmt.Errorf("unable to use client secret: %w", err) + } } - rt = NewOAuth2RoundTripper(clientSecret, cfg.OAuth2, rt, &opts) + rt = NewOAuth2RoundTripper(oauthCredential, cfg.OAuth2, rt, &opts) } if cfg.HTTPHeaders != nil { @@ -891,27 +942,31 @@ type oauth2RoundTripper struct { lastSecret string // Required for interaction with Oauth2 server. - config *OAuth2 - clientSecret SecretReader - opts *httpClientOptions - client *http.Client + config *OAuth2 + oauthCredential SecretReader + opts *httpClientOptions + client *http.Client } -func NewOAuth2RoundTripper(clientSecret SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper { - if clientSecret == nil { - clientSecret = NewInlineSecret("") +func NewOAuth2RoundTripper(oauthCredential SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper { + if oauthCredential == nil { + oauthCredential = NewInlineSecret("") } return &oauth2RoundTripper{ config: config, // A correct tokenSource will be added later on. - lastRT: &oauth2.Transport{Base: next}, - opts: opts, - clientSecret: clientSecret, + lastRT: &oauth2.Transport{Base: next}, + opts: opts, + oauthCredential: oauthCredential, } } -func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret string) (client *http.Client, source oauth2.TokenSource, err error) { +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) { tlsConfig, err := NewTLSConfig(&rt.config.TLSConfig, WithSecretManager(rt.opts.secretManager)) if err != nil { return nil, nil, err @@ -949,12 +1004,49 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret str t = NewUserAgentRoundTripper(ua, t) } - config := &clientcredentials.Config{ - ClientID: rt.config.ClientID, - ClientSecret: secret, - Scopes: rt.config.Scopes, - TokenURL: rt.config.TokenURL, - EndpointParams: mapToValues(rt.config.EndpointParams), + var config oauth2TokenSourceConfig + + if rt.config.GrantType == grantTypeJWTBearer { + // RFC 7523 3.1 - JWT authorization grants + // RFC 7523 3.2 - Client Authentication Processing is not implement upstream yet, + // see https://github.com/golang/oauth2/pull/745 + + var sig *jwt.SigningMethodRSA + switch rt.config.SignatureAlgorithm { + case jwt.SigningMethodRS256.Name: + sig = jwt.SigningMethodRS256 + case jwt.SigningMethodRS384.Name: + sig = jwt.SigningMethodRS384 + case jwt.SigningMethodRS512.Name: + sig = jwt.SigningMethodRS512 + default: + sig = jwt.SigningMethodRS256 + } + + iss := rt.config.Iss + if iss == "" { + iss = rt.config.ClientID + } + config = &JwtGrantTypeConfig{ + PrivateKey: []byte(clientCredential), + PrivateKeyID: rt.config.ClientCertificateKeyID, + Scopes: rt.config.Scopes, + TokenURL: rt.config.TokenURL, + SigningAlgorithm: sig, + Iss: iss, + Subject: rt.config.ClientID, + Audience: rt.config.Audience, + PrivateClaims: rt.config.Claims, + EndpointParams: mapToValues(rt.config.EndpointParams), + } + } else { + config = &clientcredentials.Config{ + ClientID: rt.config.ClientID, + ClientSecret: clientCredential, + Scopes: rt.config.Scopes, + TokenURL: rt.config.TokenURL, + EndpointParams: mapToValues(rt.config.EndpointParams), + } } client = &http.Client{Transport: t} ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) @@ -973,8 +1065,8 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro rt.mtx.RUnlock() // Fetch the secret if it's our first run or always if the secret can change. - if !rt.clientSecret.Immutable() || needsInit { - newSecret, err := rt.clientSecret.Fetch(req.Context()) + if !rt.oauthCredential.Immutable() || needsInit { + newSecret, err := rt.oauthCredential.Fetch(req.Context()) if err != nil { return nil, fmt.Errorf("unable to read oauth2 client secret: %w", err) } diff --git a/config/http_config_test.go b/config/http_config_test.go index c5be6c331..9968d37a8 100644 --- a/config/http_config_test.go +++ b/config/http_config_test.go @@ -17,6 +17,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -112,6 +113,10 @@ var invalidHTTPClientConfigs = []struct { httpClientConfigFile: "testdata/http.conf.oauth2-secret-and-file-set.bad.yml", errMsg: "at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured", }, + { + httpClientConfigFile: "testdata/http.conf.oauth2-certificate-and-file-set.bad.yml", + errMsg: "at most one of oauth2 client_certificate_key, client_certificate_key_file & client_certificate_key_ref must be configured using grant-type=urn:ietf:params:oauth:grant-type:jwt-beare", + }, { httpClientConfigFile: "testdata/http.conf.oauth2-no-client-id.bad.yaml", errMsg: "oauth2 client_id must be configured", @@ -1439,11 +1444,17 @@ type testOAuthServer struct { } // newTestOAuthServer returns a new test server with the expected base64 encoded client ID and secret. -func newTestOAuthServer(t testing.TB, expectedAuth *string) testOAuthServer { +func newTestOAuthServer(t testing.TB, expectedAuth func(testing.TB, string)) testOAuthServer { var previousAuth string tokenTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") - require.Equalf(t, *expectedAuth, auth, "bad auth, expected %s, got %s", *expectedAuth, auth) + if auth == "" { + require.NoErrorf(t, r.ParseForm(), "Failed to parse form") + auth = r.FormValue("assertion") + } + + expectedAuth(t, auth) + require.NotEqualf(t, auth, previousAuth, "token endpoint called twice") previousAuth = auth res, _ := json.Marshal(oauth2TestServerResponse{ @@ -1478,8 +1489,10 @@ func (s *testOAuthServer) close() { } func TestOAuth2(t *testing.T) { - var expectedAuth string - ts := newTestOAuthServer(t, &expectedAuth) + 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() yamlConfig := fmt.Sprintf(` @@ -1513,7 +1526,7 @@ endpoint_params: } // Default secret. - expectedAuth = "Basic MToy" + *expectedAuth = "Basic MToy" resp, err := client.Get(ts.url()) require.NoError(t, err) @@ -1525,7 +1538,7 @@ endpoint_params: require.NoError(t, err) // Empty secret. - expectedAuth = "Basic MTo=" + *expectedAuth = "Basic MTo=" expectedConfig.ClientSecret = "" resp, err = client.Get(ts.url()) require.NoError(t, err) @@ -1538,7 +1551,7 @@ endpoint_params: require.NoError(t, err) // Update secret. - expectedAuth = "Basic MToxMjM0NTY3" + *expectedAuth = "Basic MToxMjM0NTY3" expectedConfig.ClientSecret = "1234567" _, err = client.Get(ts.url()) require.NoError(t, err) @@ -1607,8 +1620,10 @@ func TestHost(t *testing.T) { } func TestOAuth2WithFile(t *testing.T) { - var expectedAuth string - ts := newTestOAuthServer(t, &expectedAuth) + 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() secretFile, err := os.CreateTemp("", "oauth2_secret") @@ -1646,7 +1661,7 @@ endpoint_params: } // Empty secret file. - expectedAuth = "Basic MTo=" + *expectedAuth = "Basic MTo=" resp, err := client.Get(ts.url()) require.NoError(t, err) @@ -1658,7 +1673,7 @@ endpoint_params: require.NoError(t, err) // File populated. - expectedAuth = "Basic MToxMjM0NTY=" + *expectedAuth = "Basic MToxMjM0NTY=" _, err = secretFile.Write([]byte("123456")) require.NoError(t, err) resp, err = client.Get(ts.url()) @@ -1672,7 +1687,7 @@ endpoint_params: require.NoError(t, err) // Update file. - expectedAuth = "Basic MToxMjM0NTY3" + *expectedAuth = "Basic MToxMjM0NTY3" _, err = secretFile.Write([]byte("7")) require.NoError(t, err) _, err = client.Get(ts.url()) @@ -1686,6 +1701,86 @@ endpoint_params: require.Equalf(t, "Bearer 12345", authorization, "Expected authorization header to be 'Bearer 12345', got '%s'", authorization) } +func TestOAuth2WithJWTAuth(t *testing.T) { + ts := newTestOAuthServer(t, func(t testing.TB, auth string) { + t.Helper() + + jwtParts := strings.Split(auth, ".") + require.Lenf(t, jwtParts, 3, "Expected JWT to have 3 parts, got %d", len(jwtParts)) + + // Decode the JWT payload. + payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1]) + require.NoErrorf(t, err, "Failed to decode JWT payload: %v", err) + + var jwt struct { + Aud string `json:"aud"` + Scope string `json:"scope"` + Sub string `json:"sub"` + Iss string `json:"iss"` + Integer int `json:"integer"` + } + + err = json.Unmarshal(payload, &jwt) + require.NoErrorf(t, err, "Failed to unmarshal JWT payload: %v", err) + + require.Equalf(t, "common-test", jwt.Aud, "Expected aud to be 'common-test', got '%s'", jwt.Aud) + require.Equalf(t, "A B", jwt.Scope, "Expected scope to be 'A B', got '%s'", jwt.Scope) + require.Equalf(t, "common", jwt.Sub, "Expected sub to be 'common', got '%s'", jwt.Sub) + require.Equalf(t, "https://example.com", jwt.Iss, "Expected iss to be 'https://example.com', got '%s'", jwt.Iss) + require.Equalf(t, 1, jwt.Integer, "Expected integer to be 1, got '%d'", jwt.Integer) + }) + defer ts.close() + + yamlConfig := fmt.Sprintf(` +grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer +client_id: 1 +client_certificate_key_file: %s +scopes: + - A + - B +claims: + iss: "https://example.com" + aud: common-test + sub: common + integer: 1 +token_url: %s +endpoint_params: + hi: hello +`, ClientKeyNoPassPath, ts.tokenURL()) + expectedConfig := OAuth2{ + GrantType: grantTypeJWTBearer, + ClientID: "1", + ClientCertificateKeyFile: ClientKeyNoPassPath, + Scopes: []string{"A", "B"}, + TokenURL: ts.tokenURL(), + EndpointParams: map[string]string{"hi": "hello"}, + Claims: map[string]interface{}{ + "iss": "https://example.com", + "aud": "common-test", + "sub": "common", + "integer": 1, + }, + } + + 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) + + clientCertificateKey := NewFileSecret(expectedConfig.ClientCertificateKeyFile) + rt := NewOAuth2RoundTripper(clientCertificateKey, &expectedConfig, http.DefaultTransport, &defaultHTTPClientOptions) + + client := http.Client{ + Transport: rt, + } + + 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', got '%s'", authorization) +} + func TestMarshalURL(t *testing.T) { urlp, err := url.Parse("http://example.com/") require.NoError(t, err) diff --git a/config/oauth_assertion.go b/config/oauth_assertion.go new file mode 100644 index 000000000..bf4bcb949 --- /dev/null +++ b/config/oauth_assertion.go @@ -0,0 +1,194 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/oauth2" +) + +var ( + defaultGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + validSignatureAlgorithm = []string{"RS256", "RS384", "RS512"} +) + +// Config is the configuration for using JWT to fetch tokens, +// commonly known as "two-legged OAuth 2.0". +type JwtGrantTypeConfig struct { + // Iss is the OAuth client identifier used when communicating with + // the configured OAuth provider. + Iss string + + // PrivateKey contains the contents of an RSA private key or the + // contents of a PEM file that contains a private key. The provided + // private key is used to sign JWT payloads. + // PEM containers with a passphrase are not supported. + // Use the following command to convert a PKCS 12 file into a PEM. + // + // $ openssl pkcs12 -in key.p12 -out key.pem -nodes + // + PrivateKey []byte + + // SigningAlgorithm is the RSA algorithm used to sign JWT payloads + SigningAlgorithm *jwt.SigningMethodRSA + + // PrivateKeyID contains an optional hint indicating which key is being + // used. + PrivateKeyID string + + // Subject is the optional user to impersonate. + Subject string + + // Scopes optionally specifies a list of requested permission scopes. + Scopes []string + + // TokenURL is the endpoint required to complete the 2-legged JWT flow. + TokenURL string + + // EndpointParams specifies additional parameters for requests to the token endpoint. + EndpointParams url.Values + + // Expires optionally specifies how long the token is valid for. + Expires time.Duration + + // Audience optionally specifies the intended audience of the + // request. If empty, the value of TokenURL is used as the + // intended audience. + Audience string + + // PrivateClaims optionally specifies custom private claims in the JWT. + // See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3 + PrivateClaims map[string]any +} + +// TokenSource returns a JWT TokenSource using the configuration +// in c and the HTTP client from the provided context. +func (c *JwtGrantTypeConfig) TokenSource(ctx context.Context) oauth2.TokenSource { + return oauth2.ReuseTokenSource(nil, jwtSource{ctx, c}) +} + +// Client returns an HTTP client wrapping the context's +// HTTP transport and adding Authorization headers with tokens +// obtained from c. +// +// The returned client and its Transport should not be modified. +func (c *JwtGrantTypeConfig) Client(ctx context.Context) *http.Client { + return oauth2.NewClient(ctx, c.TokenSource(ctx)) +} + +// jwtSource is a source that always does a signed JWT request for a token. +// It should typically be wrapped with a reuseTokenSource. +type jwtSource struct { + ctx context.Context + conf *JwtGrantTypeConfig +} + +func (js jwtSource) Token() (*oauth2.Token, error) { + pk, err := jwt.ParseRSAPrivateKeyFromPEM(js.conf.PrivateKey) + if err != nil { + return nil, err + } + hc := oauth2.NewClient(js.ctx, nil) + audience := js.conf.TokenURL + if aud := js.conf.Audience; aud != "" { + audience = aud + } + expiration := time.Now().Add(10 * time.Minute) + if t := js.conf.Expires; t > 0 { + expiration = time.Now().Add(t) + } + scopes := strings.Join(js.conf.Scopes, " ") + + claims := jwt.MapClaims{ + "iss": js.conf.Iss, + "sub": js.conf.Subject, + "jti": uuid.New(), + "aud": audience, + "iat": jwt.NewNumericDate(time.Now()), + "exp": jwt.NewNumericDate(expiration), + } + + if len(scopes) > 0 { + claims["scope"] = scopes + } + + for k, v := range js.conf.PrivateClaims { + claims[k] = v + } + + assertion := jwt.NewWithClaims(js.conf.SigningAlgorithm, claims) + if js.conf.PrivateKeyID != "" { + assertion.Header["kid"] = js.conf.PrivateKeyID + } + payload, err := assertion.SignedString(pk) + if err != nil { + return nil, err + } + v := url.Values{} + v.Set("grant_type", defaultGrantType) + v.Set("assertion", payload) + if len(scopes) > 0 { + v.Set("scope", scopes) + } + + for k, p := range js.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 + } + + resp, err := hc.PostForm(js.conf.TokenURL, v) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %w", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + // tokenRes is the JSON response body. + var tokenRes struct { + oauth2.Token + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %w", err) + } + token := &oauth2.Token{ + AccessToken: tokenRes.AccessToken, + TokenType: tokenRes.TokenType, + } + if secs := tokenRes.ExpiresIn; secs > 0 { + token.Expiry = time.Now().Add(time.Duration(secs) * time.Second) + } + return token, nil +} diff --git a/config/testdata/http.conf.oauth2-certificate-and-file-set.bad.yml b/config/testdata/http.conf.oauth2-certificate-and-file-set.bad.yml new file mode 100644 index 000000000..d2a5b079b --- /dev/null +++ b/config/testdata/http.conf.oauth2-certificate-and-file-set.bad.yml @@ -0,0 +1,6 @@ +oauth2: + client_id: "myclient" + client_certificate_key: "mysecret" + client_certificate_key_file: "mysecret" + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer" + token_url: "http://auth" diff --git a/go.mod b/go.mod index d3f14ed9c..fbface646 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.24.0 require ( github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 github.com/julienschmidt/httprouter v1.3.0 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f diff --git a/go.sum b/go.sum index d0a6bacc4..e1fa1b08b 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,12 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= From cd1ab56cc1e1d41dbc286d2e501e26515400b9be Mon Sep 17 00:00:00 2001 From: Bryan Boreham Date: Fri, 14 Nov 2025 10:41:28 +0000 Subject: [PATCH 2/5] Config: remove outdated comment about HTTP/2 issues All the linked issues are resolved. Signed-off-by: Bryan Boreham --- config/http_config.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/config/http_config.go b/config/http_config.go index 93fff55ab..bc906bc65 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -662,12 +662,6 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon DialContext: dialContext, } if opts.http2Enabled && cfg.EnableHTTP2 { - // HTTP/2 support is golang had many problematic cornercases where - // dead connections would be kept and used in connection pools. - // https://github.com/golang/go/issues/32388 - // https://github.com/golang/go/issues/39337 - // https://github.com/golang/go/issues/39750 - http2t, err := http2.ConfigureTransports(rt.(*http.Transport)) if err != nil { return nil, err From 0b2fbf31f0e2c21d9e1a4e51e698188fae258cb2 Mon Sep 17 00:00:00 2001 From: Matthieu MOREL Date: Tue, 18 Nov 2025 11:24:01 +0100 Subject: [PATCH 3/5] chore: clean up golangci-lint configuration (#782) * chore: clean up golangci-lint configuration Signed-off-by: Matthieu MOREL * Update .golangci.yml to remove unused linters Signed-off-by: Matthieu MOREL * Update .golangci.yml to remove excluded paths Removed specific paths from the golangci-lint configuration. Signed-off-by: Matthieu MOREL --------- Signed-off-by: Matthieu MOREL Signed-off-by: Ben Kochie Co-authored-by: Ben Kochie --- .golangci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index db2ad414c..110b428f6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -162,6 +162,4 @@ formatters: extra-rules: true goimports: local-prefixes: - - github.com/prometheus/common - exclusions: - generated: lax + - github.com/prometheus/common \ No newline at end of file From 04686b2cfc6804598d99b86070135f9266998c59 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Ferrero Date: Thu, 20 Nov 2025 09:17:00 +0100 Subject: [PATCH 4/5] chore: 'omitempty' to Oauth2 fields with type Secret to avoid requiring them (#864) Signed-off-by: Jorge Turrado --- config/http_config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/http_config.go b/config/http_config.go index bc906bc65..7dce315d5 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -239,13 +239,13 @@ func (u URL) MarshalJSON() ([]byte, error) { // OAuth2 is the oauth2 client configuration. type OAuth2 struct { ClientID string `yaml:"client_id" json:"client_id"` - ClientSecret Secret `yaml:"client_secret" json:"client_secret"` + ClientSecret Secret `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` ClientSecretFile string `yaml:"client_secret_file" json:"client_secret_file"` // ClientSecretRef is the name of the secret within the secret manager to use as the client // secret. ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"` ClientCertificateKeyID string `yaml:"client_certificate_key_id" json:"client_certificate_key_id"` - ClientCertificateKey Secret `yaml:"client_certificate_key" json:"client_certificate_key"` + ClientCertificateKey Secret `yaml:"client_certificate_key,omitempty" json:"client_certificate_key,omitempty"` ClientCertificateKeyFile string `yaml:"client_certificate_key_file" json:"client_certificate_key_file"` // ClientCertificateKeyRef is the name of the secret within the secret manager to use as the client // secret. From d80d8544703e59a080a204b6f7429ac6561fb24f Mon Sep 17 00:00:00 2001 From: Jorge Turrado Ferrero Date: Fri, 21 Nov 2025 14:28:44 +0100 Subject: [PATCH 5/5] chore: Add omitempty tag to all config fields (#865) * chore: Add omitempty tag to all config fields Signed-off-by: Jorge Turrado * chore: Add omitempty tag to all config fields Signed-off-by: Jorge Turrado * chore: Add omitempty tag to all config fields Signed-off-by: Jorge Turrado --------- Signed-off-by: Jorge Turrado --- config/http_config.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/config/http_config.go b/config/http_config.go index 7dce315d5..55cc5b077 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -136,7 +136,7 @@ func (tv *TLSVersion) String() string { // BasicAuth contains basic HTTP authentication credentials. type BasicAuth struct { - Username string `yaml:"username" json:"username"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` UsernameFile string `yaml:"username_file,omitempty" json:"username_file,omitempty"` // UsernameRef is the name of the secret within the secret manager to use as the username. UsernameRef string `yaml:"username_ref,omitempty" json:"username_ref,omitempty"` @@ -238,22 +238,22 @@ func (u URL) MarshalJSON() ([]byte, error) { // OAuth2 is the oauth2 client configuration. type OAuth2 struct { - ClientID string `yaml:"client_id" json:"client_id"` + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` ClientSecret Secret `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` - ClientSecretFile string `yaml:"client_secret_file" json:"client_secret_file"` + ClientSecretFile string `yaml:"client_secret_file,omitempty" json:"client_secret_file,omitempty"` // ClientSecretRef is the name of the secret within the secret manager to use as the client // secret. - ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"` - ClientCertificateKeyID string `yaml:"client_certificate_key_id" json:"client_certificate_key_id"` + ClientSecretRef string `yaml:"client_secret_ref,omitempty" json:"client_secret_ref,omitempty"` + ClientCertificateKeyID string `yaml:"client_certificate_key_id,omitempty" json:"client_certificate_key_id,omitempty"` ClientCertificateKey Secret `yaml:"client_certificate_key,omitempty" json:"client_certificate_key,omitempty"` - ClientCertificateKeyFile string `yaml:"client_certificate_key_file" json:"client_certificate_key_file"` + ClientCertificateKeyFile string `yaml:"client_certificate_key_file,omitempty" json:"client_certificate_key_file,omitempty"` // ClientCertificateKeyRef is the name of the secret within the secret manager to use as the client // secret. - ClientCertificateKeyRef string `yaml:"client_certificate_key_ref" json:"client_certificate_key_ref"` + ClientCertificateKeyRef string `yaml:"client_certificate_key_ref,omitempty" json:"client_certificate_key_ref,omitempty"` // GrantType is the OAuth2 grant type to use. It can be one of // "client_credentials" or "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523). // Default value is "client_credentials" - GrantType string `yaml:"grant_type" json:"grant_type"` + GrantType string `yaml:"grant_type,omitempty" json:"grant_type,omitempty"` // SignatureAlgorithm is the RSA algorithm used to sign JWT token. Only used if // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". // Default value is RS256 and valid values RS256, RS384, RS512 @@ -271,7 +271,7 @@ type OAuth2 struct { // GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". Claims map[string]interface{} `yaml:"claims,omitempty" json:"claims,omitempty"` Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` - TokenURL string `yaml:"token_url" json:"token_url"` + TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"` EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"` TLSConfig TLSConfig `yaml:"tls_config,omitempty"` ProxyConfig `yaml:",inline"`