diff --git a/conf/defaults.ini b/conf/defaults.ini index 081d660a5d8..c0f116e6a3e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -949,6 +949,7 @@ username_claim = email_attribute_path = username_attribute_path = jwk_set_url = +jwk_set_bearer_token_file = jwk_set_file = cache_ttl = 60m expect_claims = {} @@ -963,6 +964,7 @@ auto_sign_up = false url_login = false allow_assign_grafana_admin = false skip_org_role_sync = false +tls_client_ca = tls_skip_verify_insecure = false #################################### Auth LDAP ########################### diff --git a/conf/sample.ini b/conf/sample.ini index fb6550c23f1..376f9f45231 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -912,6 +912,7 @@ ;email_attribute_path = jmespath.email ;username_attribute_path = jmespath.username ;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 ;cache_ttl = 60m ;expect_claims = {"aud": ["foo", "bar"]} @@ -928,6 +929,7 @@ ;allow_assign_grafana_admin = false ;skip_org_role_sync = false ;signout_redirect_url = +;tls_client_ca = ;tls_skip_verify_insecure = false #################################### Auth LDAP ########################## diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index ee945cf4434..a6de78e495c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -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 -# Cache TTL for data loaded from http endpoint. +# When the JWKS url requires an 'Authorization: Bearer ' header +# jwk_set_bearer_token_file = /path/to/bearer_token + +# Cache duration for https endpoint response. 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" >}} -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 >}} ### Verify token using a JSON Web Key Set loaded from JSON file diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index 18ce9f5dcad..8e85c464c37 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -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() +} diff --git a/pkg/services/auth/jwt/key_sets.go b/pkg/services/auth/jwt/key_sets.go index 2e5d6b22001..b569546b6b1 100644 --- a/pkg/services/auth/jwt/key_sets.go +++ b/pkg/services/auth/jwt/key_sets.go @@ -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 diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go index 18c7866cccf..af069db8fda 100644 --- a/pkg/setting/setting_jwt.go +++ b/pkg/setting/setting_jwt.go @@ -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", ""))