Auth: Support JWT configs tls_client_ca and jwk_set_bearer_token_file (#109095)

* Auth.jwt: Support config tls_client_ca

* Auth.jwt: Support config jwk_set_bearer_token_file

* Docs: Document new JWKS url options and mention tls_skip_verify_insecure

* Docs: Fix note on JWKS response caching

* chore: Refactor getBearerToken into standalone function

* docs: Apply wording/formatting suggestions

Co-authored-by: Victor Cinaglia <victorcinaglia@gmail.com>

* chore: Simplify ca helper function using testcerts

Co-authored-by: Victor Cinaglia <victorcinaglia@gmail.com>

* chore: Update doc and add comment preventing potential erroneous optimization

Co-authored-by: Victor Cinaglia <victorcinaglia@gmail.com>

chore: Reword comment prevent an erroneous refactor

* docs: Update casing

Co-authored-by: Victor Cinaglia <victorcinaglia@gmail.com>

---------

Co-authored-by: Victor Cinaglia <victorcinaglia@gmail.com>
This commit is contained in:
Steffen Baarsgaard
2025-08-26 09:50:06 -03:00
committed by GitHub
co-authored by Victor Cinaglia
parent 145577831b
commit b047175330
6 changed files with 294 additions and 4 deletions
+208
View File
@@ -15,6 +15,7 @@ import (
jose "github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/madflojo/testcerts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -169,6 +170,201 @@ func TestIntegrationVerifyUsingJWKSetURL(t *testing.T) {
})
}
// test that caCert and bearer token files have been read and configured and an error is thrown when the file does not exist or is empty
func TestIntegrationCustomRootCAJWKHTTPSClient(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
urlConfigure := func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuth.JWKSetURL = "https://example.com/.well-known/jwks.json"
}
t.Run("tls_client_ca being empty returns nil RootCAs", func(t *testing.T) {
s, err := initAuthService(t, urlConfigure)
require.NoError(t, err)
ks := s.keySet.(*keySetHTTP)
assert.Nil(t, ks.client.Transport.(*http.Transport).TLSClientConfig.RootCAs)
})
t.Run("tls_client_ca path is read and added to client.RootCAs", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
caFilename := createTestRootCAFile(t)
t.Cleanup(func() {
if err := os.Remove(caFilename); err != nil {
panic(err)
}
})
cfg.JWTAuth.TlsClientCa = caFilename
}
s, err := initAuthService(t, urlConfigure, configure)
require.NoError(t, err)
ks := s.keySet.(*keySetHTTP)
rootCAs := ks.client.Transport.(*http.Transport).TLSClientConfig.RootCAs
assert.NotNil(t, rootCAs)
})
t.Run("error when tls_client_ca file does not exist", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
// Create and remove tmp file to guarantee the path does not exist
file, err := os.CreateTemp(os.TempDir(), "ca-*.crt")
require.NoError(t, err)
require.NoError(t, os.Remove(file.Name()))
cfg.JWTAuth.TlsClientCa = file.Name()
}
_, err := initAuthService(t, urlConfigure, configure)
require.Error(t, err)
})
t.Run("error when tls_client_ca path does not contain PEM certs", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
file, err := os.CreateTemp(os.TempDir(), "ca-*.crt")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
cfg.JWTAuth.TlsClientCa = file.Name()
}
_, err := initAuthService(t, urlConfigure, configure)
require.Error(t, err)
})
}
func TestIntegrationAuthorizationHeaderJWKHTTPSClient(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
urlConfigure := func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuth.JWKSetURL = "https://example.com/.well-known/jwks.json"
}
t.Run("jwk_set_bearer_token_file being empty returns no token", func(t *testing.T) {
_, err := initAuthService(t, urlConfigure)
require.NoError(t, err)
token, err := getBearerToken("")
assert.Empty(t, token)
assert.Error(t, err) // Error is expected as getBearerToken is only invoked when bearer token file is configured
})
t.Run("jwk_set_bearer_token_file is read and added to headers", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
file, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
_, err = file.WriteString("fake_token_string")
require.NoError(t, err)
cfg.JWTAuth.JWKSetBearerTokenFile = file.Name()
}
s, err := initAuthService(t, urlConfigure, configure)
require.NoError(t, err)
token, err := getBearerToken(s.keySet.(*keySetHTTP).bearerTokenPath)
assert.Equal(t, "Bearer fake_token_string", token, "Token should have been prefixed with 'Bearer '")
assert.NoError(t, err)
})
t.Run("jwk_set_bearer_token_file prefix is not doubled", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
file, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
_, err = file.WriteString("Bearer fake_token_string")
require.NoError(t, err)
cfg.JWTAuth.JWKSetBearerTokenFile = file.Name()
}
s, err := initAuthService(t, urlConfigure, configure)
require.NoError(t, err)
token, err := getBearerToken(s.keySet.(*keySetHTTP).bearerTokenPath)
assert.Equal(t, "Bearer fake_token_string", token, "Token should have kept existing prefix")
assert.NoError(t, err)
})
t.Run("jwk_set_bearer_token_file file is just spaces", func(t *testing.T) {
// Create file outside 'configure' as getBearerToken needs to know the path
// As initAuthService returns an error when token is missing
file, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
configure := func(t *testing.T, cfg *setting.Cfg) {
_, err = file.WriteString(" ")
require.NoError(t, err)
cfg.JWTAuth.JWKSetBearerTokenFile = file.Name()
}
s, err := initAuthService(t, urlConfigure, configure)
require.Nil(t, s.keySet)
require.Error(t, err)
token, err := getBearerToken(file.Name())
assert.Equal(t, "", token, "Should return an empty token")
assert.Error(t, err)
})
t.Run("error when jwk_set_bearer_token_file does not exist", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
// Create and remove tmp file to guarantee the path does not exist
file, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
require.NoError(t, os.Remove(file.Name()))
cfg.JWTAuth.JWKSetBearerTokenFile = file.Name()
}
_, err := initAuthService(t, urlConfigure, configure)
require.Error(t, err)
})
t.Run("error when jwk_set_bearer_token_file does not contain a token", func(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
file, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
cfg.JWTAuth.JWKSetBearerTokenFile = file.Name()
}
_, err := initAuthService(t, urlConfigure, configure)
require.Error(t, err)
})
}
func TestIntegrationCachingJWKHTTPResponse(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
@@ -465,3 +661,15 @@ func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuth.KeyFile = file.Name()
}
func createTestRootCAFile(t *testing.T) (filename string) {
t.Helper()
tmpDir := t.TempDir()
ca := testcerts.NewCA()
caCertFile, _, err := ca.ToTempFile(tmpDir)
require.NoError(t, err)
return caCertFile.Name()
}
+65 -2
View File
@@ -42,6 +42,7 @@ type keySetHTTP struct {
url string
log log.Logger
client *http.Client
bearerTokenPath string
cache *remotecache.RemoteCache
cacheKey string
cacheExpiration time.Duration
@@ -70,6 +71,8 @@ func (s *AuthService) checkKeySetConfiguration() error {
return nil
}
// initKeySet creates a provider for JWKSet, either file, or https
// nolint:gocyclo
func (s *AuthService) initKeySet() error {
if err := s.checkKeySetConfiguration(); err != nil {
return err
@@ -155,14 +158,42 @@ func (s *AuthService) initKeySet() error {
if urlParsed.Scheme != "https" && s.Cfg.Env != setting.Dev {
return ErrJWTSetURLMustHaveHTTPSScheme
}
var caCertPool *x509.CertPool
if s.Cfg.JWTAuth.TlsClientCa != "" {
s.log.Debug("reading ca from TlsClientCa path")
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `tlsClientCa` comes from grafana configuration file
caCert, err := os.ReadFile(s.Cfg.JWTAuth.TlsClientCa)
if err != nil {
s.log.Error("Failed to read TlsClientCa", "path", s.Cfg.JWTAuth.TlsClientCa, "error", err)
return fmt.Errorf("failed to read TlsClientCa: %w", err)
}
caCertPool = x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
s.log.Error("failed to decode provided PEM certs", "path", s.Cfg.JWTAuth.TlsClientCa)
return fmt.Errorf("failed to decode provided PEM certs file from TlsClientCa")
}
}
// Read Bearer token from file during init
if s.Cfg.JWTAuth.JWKSetBearerTokenFile != "" {
if _, err := getBearerToken(s.Cfg.JWTAuth.JWKSetBearerTokenFile); err != nil {
return err
}
}
s.keySet = &keySetHTTP{
url: urlStr,
log: s.log,
url: urlStr,
log: s.log,
bearerTokenPath: s.Cfg.JWTAuth.JWKSetBearerTokenFile,
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Renegotiation: tls.RenegotiateFreelyAsClient,
InsecureSkipVerify: s.Cfg.JWTAuth.TlsSkipVerify,
RootCAs: caCertPool,
},
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
@@ -189,6 +220,27 @@ func (ks *keySetJWKS) Key(ctx context.Context, keyID string) ([]jose.JSONWebKey,
return ks.JSONWebKeySet.Key(keyID), nil
}
func getBearerToken(bearerTokenPath string) (string, error) {
// nolint:gosec
// We can ignore the gosec G304 warning as `bearerTokenPath` originates from grafana configuration file
bytes, err := os.ReadFile(bearerTokenPath)
if err != nil {
return "", fmt.Errorf("failed to read JWKSetBearerTokenFile: %w", err)
}
t := strings.TrimSpace(string(bytes))
if len(t) == 0 {
return "", fmt.Errorf("empty file configured for JWKSetBearerTokenFile")
}
if strings.HasPrefix(t, "Bearer ") {
return t, nil
}
// Prefix with Bearer if missing
return fmt.Sprintf("Bearer %s", t), nil
}
func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
var jwks keySetJWKS
@@ -210,6 +262,17 @@ func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
return jwks, err
}
if ks.bearerTokenPath != "" {
// Always read the token before fetching JWKS to handle potential key rotation (e.g. short-lived kubernetes ServiceAccount tokens)
token, err := getBearerToken(ks.bearerTokenPath)
if err != nil {
return jwks, err
}
ks.log.Debug("adding Authorization header", "token_len", len(token))
req.Header.Set("Authorization", token)
}
resp, err := ks.client.Do(req)
if err != nil {
return jwks, err
+4
View File
@@ -19,6 +19,7 @@ type AuthJWTSettings struct {
UsernameClaim string
ExpectClaims string
JWKSetURL string
JWKSetBearerTokenFile string
CacheTTL time.Duration
KeyFile string
KeyID string
@@ -33,6 +34,7 @@ type AuthJWTSettings struct {
GroupsAttributePath string
EmailAttributePath string
UsernameAttributePath string
TlsClientCa string
TlsSkipVerify bool
}
@@ -64,6 +66,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "")
jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}")
jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "")
jwtSettings.JWKSetBearerTokenFile = valueAsString(authJWT, "jwk_set_bearer_token_file", "")
jwtSettings.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "")
jwtSettings.KeyID = authJWT.Key("key_id").MustString("")
@@ -76,6 +79,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "")
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "")
jwtSettings.TlsClientCa = valueAsString(authJWT, "tls_client_ca", "")
jwtSettings.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false)
jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "")
jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", ""))