mirror of https://github.com/grafana/grafana.git
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:
parent
145577831b
commit
b047175330
|
@ -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 ###########################
|
||||
|
|
|
@ -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 ##########################
|
||||
|
|
|
@ -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 <TOKEN>' 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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
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
|
||||
|
|
|
@ -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", ""))
|
||||
|
|
Loading…
Reference in New Issue