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 14:50:06 +02:00 committed by GitHub
parent 145577831b
commit b047175330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 294 additions and 4 deletions

View File

@ -949,6 +949,7 @@ username_claim =
email_attribute_path = email_attribute_path =
username_attribute_path = username_attribute_path =
jwk_set_url = jwk_set_url =
jwk_set_bearer_token_file =
jwk_set_file = jwk_set_file =
cache_ttl = 60m cache_ttl = 60m
expect_claims = {} expect_claims = {}
@ -963,6 +964,7 @@ auto_sign_up = false
url_login = false url_login = false
allow_assign_grafana_admin = false allow_assign_grafana_admin = false
skip_org_role_sync = false skip_org_role_sync = false
tls_client_ca =
tls_skip_verify_insecure = false tls_skip_verify_insecure = false
#################################### Auth LDAP ########################### #################################### Auth LDAP ###########################

View File

@ -912,6 +912,7 @@
;email_attribute_path = jmespath.email ;email_attribute_path = jmespath.email
;username_attribute_path = jmespath.username ;username_attribute_path = jmespath.username
;jwk_set_url = https://foo.bar/.well-known/jwks.json ;jwk_set_url = https://foo.bar/.well-known/jwks.json
;jwk_set_bearer_token_file = /path/to/token/file
;jwk_set_file = /path/to/jwks.json ;jwk_set_file = /path/to/jwks.json
;cache_ttl = 60m ;cache_ttl = 60m
;expect_claims = {"aud": ["foo", "bar"]} ;expect_claims = {"aud": ["foo", "bar"]}
@ -928,6 +929,7 @@
;allow_assign_grafana_admin = false ;allow_assign_grafana_admin = false
;skip_org_role_sync = false ;skip_org_role_sync = false
;signout_redirect_url = ;signout_redirect_url =
;tls_client_ca =
;tls_skip_verify_insecure = false ;tls_skip_verify_insecure = false
#################################### Auth LDAP ########################## #################################### Auth LDAP ##########################

View File

@ -157,12 +157,23 @@ For more information on JWKS endpoints, refer to [Auth0 docs](https://auth0.com/
jwk_set_url = https://your-auth-provider.example.com/.well-known/jwks.json jwk_set_url = https://your-auth-provider.example.com/.well-known/jwks.json
# Cache TTL for data loaded from http endpoint. # When the JWKS url requires an 'Authorization: Bearer <TOKEN>' header
# jwk_set_bearer_token_file = /path/to/bearer_token
# Cache duration for https endpoint response.
cache_ttl = 60m cache_ttl = 60m
# Path to file containing one or more custom PEM-encoded CA certificates.
# Used with jwk_set_url when the JWKS endpoint uses a certificate that is not
# trusted by the default CA bundle (e.g. self-signed certificates).
# tls_client_ca = /path/to/ca.crt
# Skip CA Verification entirely
# tls_skip_verify_insecure = false
``` ```
{{< admonition type="note" >}} {{< admonition type="note" >}}
If the JWKS endpoint includes cache control headers and the value is less than the configured `cache_ttl`, then the cache control header value is used instead. If the `cache_ttl` is not set, no caching is performed. `no-store` and `no-cache` cache control headers are ignored. If the JWKS endpoint includes cache control headers and the value is less than the configured `cache_ttl`, then the cache control header value is used instead. If the `cache_ttl` is not set, the default of `60m` is used. `no-store` and `no-cache` cache control headers are ignored. To disable JWKS caching, set `cache_ttl = 0s`
{{< /admonition >}} {{< /admonition >}}
### Verify token using a JSON Web Key Set loaded from JSON file ### Verify token using a JSON Web Key Set loaded from JSON file

View File

@ -15,6 +15,7 @@ import (
jose "github.com/go-jose/go-jose/v3" jose "github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt" "github.com/go-jose/go-jose/v3/jwt"
"github.com/madflojo/testcerts"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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) { func TestIntegrationCachingJWKHTTPResponse(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test in short mode") 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() 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()
}

View File

@ -42,6 +42,7 @@ type keySetHTTP struct {
url string url string
log log.Logger log log.Logger
client *http.Client client *http.Client
bearerTokenPath string
cache *remotecache.RemoteCache cache *remotecache.RemoteCache
cacheKey string cacheKey string
cacheExpiration time.Duration cacheExpiration time.Duration
@ -70,6 +71,8 @@ func (s *AuthService) checkKeySetConfiguration() error {
return nil return nil
} }
// initKeySet creates a provider for JWKSet, either file, or https
// nolint:gocyclo
func (s *AuthService) initKeySet() error { func (s *AuthService) initKeySet() error {
if err := s.checkKeySetConfiguration(); err != nil { if err := s.checkKeySetConfiguration(); err != nil {
return err return err
@ -155,14 +158,42 @@ func (s *AuthService) initKeySet() error {
if urlParsed.Scheme != "https" && s.Cfg.Env != setting.Dev { if urlParsed.Scheme != "https" && s.Cfg.Env != setting.Dev {
return ErrJWTSetURLMustHaveHTTPSScheme 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{ s.keySet = &keySetHTTP{
url: urlStr, url: urlStr,
log: s.log, log: s.log,
bearerTokenPath: s.Cfg.JWTAuth.JWKSetBearerTokenFile,
client: &http.Client{ client: &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
Renegotiation: tls.RenegotiateFreelyAsClient, Renegotiation: tls.RenegotiateFreelyAsClient,
InsecureSkipVerify: s.Cfg.JWTAuth.TlsSkipVerify, InsecureSkipVerify: s.Cfg.JWTAuth.TlsSkipVerify,
RootCAs: caCertPool,
}, },
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
@ -189,6 +220,27 @@ func (ks *keySetJWKS) Key(ctx context.Context, keyID string) ([]jose.JSONWebKey,
return ks.JSONWebKeySet.Key(keyID), nil 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) { func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
var jwks keySetJWKS var jwks keySetJWKS
@ -210,6 +262,17 @@ func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
return jwks, err 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) resp, err := ks.client.Do(req)
if err != nil { if err != nil {
return jwks, err return jwks, err

View File

@ -19,6 +19,7 @@ type AuthJWTSettings struct {
UsernameClaim string UsernameClaim string
ExpectClaims string ExpectClaims string
JWKSetURL string JWKSetURL string
JWKSetBearerTokenFile string
CacheTTL time.Duration CacheTTL time.Duration
KeyFile string KeyFile string
KeyID string KeyID string
@ -33,6 +34,7 @@ type AuthJWTSettings struct {
GroupsAttributePath string GroupsAttributePath string
EmailAttributePath string EmailAttributePath string
UsernameAttributePath string UsernameAttributePath string
TlsClientCa string
TlsSkipVerify bool TlsSkipVerify bool
} }
@ -64,6 +66,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "") jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "")
jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}") jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}")
jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "") 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.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "") jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "")
jwtSettings.KeyID = authJWT.Key("key_id").MustString("") jwtSettings.KeyID = authJWT.Key("key_id").MustString("")
@ -76,6 +79,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "") jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "")
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "") jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_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.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false)
jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "") jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "")
jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", "")) jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", ""))