diff --git a/conf/defaults.ini b/conf/defaults.ini index 801dea28d33..559049f6ac3 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -337,7 +337,7 @@ token_rotation_interval_minutes = 10 # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false -# Set to true to disable the signout link in the side menu. useful if you use auth.proxy +# Set to true to disable the sign out link in the side menu. Useful if you use auth.proxy or auth.jwt. disable_signout_menu = false # URL to redirect the user to after sign out @@ -498,6 +498,18 @@ whitelist = headers = enable_login_token = false +#################################### Auth JWT ########################## +[auth.jwt] +enabled = false +header_name = +email_claim = +username_claim = +jwk_set_url = +jwk_set_file = +cache_ttl = 60m +expected_claims = {} +key_file = + #################################### Auth LDAP ########################### [auth.ldap] enabled = false diff --git a/conf/sample.ini b/conf/sample.ini index 4ba183facc1..b5b62a23503 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -337,7 +337,7 @@ # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false -# Set to true to disable the signout link in the side menu. useful if you use auth.proxy, defaults to false +# Set to true to disable the sign out link in the side menu. Useful if you use auth.proxy or auth.jwt, defaults to false ;disable_signout_menu = false # URL to redirect the user to after sign out @@ -488,6 +488,18 @@ # Read the auth proxy docs for details on what the setting below enables ;enable_login_token = false +#################################### Auth JWT ########################## +[auth.jwt] +;enabled = true +;header_name = X-JWT-Assertion +;email_claim = sub +;username_claim = sub +;jwk_set_url = https://foo.bar/.well-known/jwks.json +;jwk_set_file = /path/to/jwks.json +;cache_ttl = 60m +;expected_claims = {"aud": ["foo", "bar"]} +;key_file = /path/to/key/file + #################################### Auth LDAP ########################## [auth.ldap] ;enabled = false diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 0e49d5af3ea..b22a4ba69e0 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -799,6 +799,12 @@ Use the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/A
+## [auth.jwt] + +Refer to [JWT authentication]({{< relref "../auth/jwt.md" >}}) for more information. + +
+ ## [smtp] Email server settings. diff --git a/docs/sources/auth/grafana.md b/docs/sources/auth/grafana.md index b19ae0d72f3..196932173b3 100644 --- a/docs/sources/auth/grafana.md +++ b/docs/sources/auth/grafana.md @@ -103,7 +103,7 @@ oauth_auto_login = true ### Hide sign-out menu -Set the option detailed below to true to hide sign-out menu link. Useful if you use an auth proxy. +Set the option detailed below to true to hide sign-out menu link. Useful if you use an auth proxy or JWT authentication. ```bash [auth] diff --git a/docs/sources/auth/jwt.md b/docs/sources/auth/jwt.md new file mode 100644 index 00000000000..c0f0e0dc83f --- /dev/null +++ b/docs/sources/auth/jwt.md @@ -0,0 +1,92 @@ ++++ +title = "JWT Authentication" +description = "Grafana JWT Authentication" +keywords = ["grafana", "configuration", "documentation", "jwt", "jwk"] +weight = 250 ++++ + +# JWT authentication + +You can configure Grafana to accept a JWT token provided in the HTTP header. The token is verified using any of the following: +- PEM-encoded key file +- JSON Web Key Set (JWKS) in a local file +- JWKS provided by the configured JWKS endpoint + +## Enable JWT + +To use JWT authentication: +1. Enable JWT in the [main config file]({{< relref "../administration/configuration.md" >}}). +1. Specify the header name that contains a token. + +```ini +[auth.jwt] +# By default, auth.jwt is disabled. +enabled = true + +# HTTP header to look into to get a JWT token. +header_name = X-JWT-Assertion +``` + +## Configure login claim + +To identify the user, some of the claims needs to be selected as a login info. You could specify a claim that contains either a username or an email of the Grafana user. + +Typically, the subject claim called `"sub"` would be used as a login but it might also be set to some application specific claim. + +```ini +# [auth.jwt] +# ... + +# Specify a claim to use as a username to sign in. +username_claim = sub + +# Specify a claim to use as an email to sign in. +email_claim = sub +``` + +## Signature verification + +JSON web token integrity needs to be verified so cryptographic signature is used for this purpose. So we expect that every token must be signed with some known cryptographic key. + +You have a variety of options on how to specify where the keys are located. + +### Verify token using a JSON Web Key Set loaded from https endpoint + +For more information on JWKS endpoints, refer to [Auth0 docs](https://auth0.com/docs/tokens/json-web-tokens/json-web-key-sets). + +```ini +# [auth.jwt] +# ... + +jwk_set_url = https://your-auth-provider.example.com/.well-known/jwks.json + +# Cache TTL for data loaded from http endpoint. +cache_ttl = 60m +``` + +### Verify token using a JSON Web Key Set loaded from JSON file + +Key set in the same format as in JWKS endpoint but located on disk. + +```ini +jwk_set_file = /path/to/jwks.json +``` + +### Verify token using a single key loaded from PEM-encoded file + +PEM-encoded key file in PKIX, PKCS #1, PKCS #8 or SEC 1 format. + +```ini +key_file = /path/to/key.pem +``` + +## Validate claims + +By default, only `"exp"`, `"nbf"` and `"iat"` claims are validated. + +You might also want to validate that other claims are really what you expect them to be. + +```ini +# This can be seen as a required "subset" of a JWT Claims Set. +expect_claims = {"iss": "https://your-token-issuer", "your-custom-claim": "foo"} +``` diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index ebe0fce8849..daf0f0d3cc8 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -18,6 +18,7 @@ Provider | Support | Role mapping | Team sync
*(Enterprise only)* | Active s [GitHub OAuth]({{< relref "github.md" >}}) | v2.0+ | - | v6.3+ | - [GitLab OAuth]({{< relref "gitlab.md" >}}) | v5.3+ | - | v6.4+ | - [Google OAuth]({{< relref "google.md" >}}) | v2.0+ | - | - | - +[JWT]({{< relref "jwt.md" >}}) | v8.0+ | - | - | - [LDAP]({{< relref "ldap.md" >}}) | v2.1+ | v2.1+ | v5.3+ | v6.3+ [Okta OAuth]({{< relref "okta.md" >}}) | v7.0+ | v7.0+ | v7.0+ | - [SAML]({{< relref "../enterprise/saml.md" >}}) (Enterprise only) | v6.3+ | v7.0+ | v7.0+ | - @@ -122,7 +123,7 @@ oauth_auto_login = true ### Hide sign-out menu -Set the option detailed below to true to hide sign-out menu link. Useful if you use an auth proxy. +Set the option detailed below to true to hide sign-out menu link. Useful if you use an auth proxy or JWT authentication. ```bash [auth] diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 5784cc47576..10c8e6499e0 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -164,6 +165,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa } userAuthTokenSvc := auth.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} + authJWTSvc := models.NewFakeJWTService() ctxHdlr := &contexthandler.ContextHandler{} err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{ @@ -183,6 +185,10 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa Name: rendering.ServiceName, Instance: renderSvc, }, + { + Name: jwt.ServiceName, + Instance: authJWTSvc, + }, { Name: contexthandler.ServiceName, Instance: ctxHdlr, diff --git a/pkg/middleware/middleware_jwt_auth_test.go b/pkg/middleware/middleware_jwt_auth_test.go new file mode 100644 index 00000000000..d0fe0ae9c8f --- /dev/null +++ b/pkg/middleware/middleware_jwt_auth_test.go @@ -0,0 +1,126 @@ +package middleware + +import ( + "context" + "errors" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +func TestMiddlewareJWTAuth(t *testing.T) { + const id int64 = 12 + const orgID int64 = 2 + + configure := func(cfg *setting.Cfg) { + cfg.JWTAuthEnabled = true + cfg.JWTAuthHeaderName = "x-jwt-assertion" + } + + configureUsernameClaim := func(cfg *setting.Cfg) { + cfg.JWTAuthUsernameClaim = "foo-username" + } + + configureEmailClaim := func(cfg *setting.Cfg) { + cfg.JWTAuthEmailClaim = "foo-email" + } + + token := "some-token" + + middlewareScenario(t, "Valid token with valid login claim", func(t *testing.T, sc *scenarioContext) { + myUsername := "vladimir" + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{ + "foo-username": myUsername, + }, nil + } + bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{ + UserId: id, + OrgId: orgID, + Login: query.Login, + } + return nil + }) + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 200, sc.resp.Code) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, id, sc.context.UserId) + assert.Equal(t, myUsername, sc.context.Login) + }, configure, configureUsernameClaim) + + middlewareScenario(t, "Valid token with valid email claim", func(t *testing.T, sc *scenarioContext) { + myEmail := "vladimir@example.com" + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{ + "foo-email": myEmail, + }, nil + } + bus.AddHandler("get-sign-user", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{ + UserId: id, + OrgId: orgID, + Email: query.Email, + } + return nil + }) + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 200, sc.resp.Code) + assert.True(t, sc.context.IsSignedIn) + assert.Equal(t, orgID, sc.context.OrgId) + assert.Equal(t, id, sc.context.UserId) + assert.Equal(t, myEmail, sc.context.Email) + }, configure, configureEmailClaim) + + middlewareScenario(t, "Valid token without a login claim", func(t *testing.T, sc *scenarioContext) { + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{"foo": "bar"}, nil + } + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, contexthandler.InvalidJWT, sc.respJson["message"]) + }, configure, configureUsernameClaim) + + middlewareScenario(t, "Valid token without a email claim", func(t *testing.T, sc *scenarioContext) { + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return models.JWTClaims{"foo": "bar"}, nil + } + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, contexthandler.InvalidJWT, sc.respJson["message"]) + }, configure, configureEmailClaim) + + middlewareScenario(t, "Invalid token", func(t *testing.T, sc *scenarioContext) { + var verifiedToken string + sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) { + verifiedToken = token + return nil, errors.New("token is invalid") + } + + sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec() + assert.Equal(t, verifiedToken, token) + assert.Equal(t, 401, sc.resp.Code) + assert.Equal(t, contexthandler.InvalidJWT, sc.respJson["message"]) + }, configure, configureUsernameClaim) +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 43518551483..cc49020e560 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/rendering" @@ -576,6 +577,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.m.Use(OrgRedirect(sc.cfg)) sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*auth.FakeUserAuthTokenService) + sc.jwtAuthService = ctxHdlr.JWTAuthService.(*models.FakeJWTService) sc.remoteCacheService = ctxHdlr.RemoteCache sc.defaultHandler = func(c *models.ReqContext) { @@ -611,6 +613,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa } userAuthTokenSvc := auth.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} + authJWTSvc := models.NewFakeJWTService() ctxHdlr := &contexthandler.ContextHandler{} err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{ @@ -630,6 +633,10 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa Name: rendering.ServiceName, Instance: renderSvc, }, + { + Name: jwt.ServiceName, + Instance: authJWTSvc, + }, { Name: contexthandler.ServiceName, Instance: ctxHdlr, diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index 20bcd7080e8..b4d2109cce1 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -24,12 +24,14 @@ type scenarioContext struct { resp *httptest.ResponseRecorder apiKey string authHeader string + jwtAuthHeader string tokenSessionCookie string respJson map[string]interface{} handlerFunc handlerFunc defaultHandler macaron.Handler url string userAuthTokenService *auth.FakeUserAuthTokenService + jwtAuthService *models.FakeJWTService remoteCacheService *remotecache.RemoteCache cfg *setting.Cfg sqlStore *sqlstore.SQLStore @@ -53,6 +55,11 @@ func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioC return sc } +func (sc *scenarioContext) withJWTAuthHeader(jwtAuthHeader string) *scenarioContext { + sc.jwtAuthHeader = jwtAuthHeader + return sc +} + func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { sc.t.Helper() @@ -95,6 +102,11 @@ func (sc *scenarioContext) exec() { sc.req.Header.Set("Authorization", sc.authHeader) } + if sc.jwtAuthHeader != "" { + sc.t.Logf(`Adding header "%s: %s"`, sc.cfg.JWTAuthHeaderName, sc.jwtAuthHeader) + sc.req.Header.Set(sc.cfg.JWTAuthHeaderName, sc.jwtAuthHeader) + } + if sc.tokenSessionCookie != "" { sc.t.Log(`Adding cookie`, "name", sc.cfg.LoginCookieName, "value", sc.tokenSessionCookie) sc.req.AddCookie(&http.Cookie{ diff --git a/pkg/models/jwt.go b/pkg/models/jwt.go new file mode 100644 index 00000000000..b8c884d9bf4 --- /dev/null +++ b/pkg/models/jwt.go @@ -0,0 +1,31 @@ +package models + +import ( + "context" +) + +type JWTClaims map[string]interface{} + +type JWTService interface { + Verify(ctx context.Context, strToken string) (JWTClaims, error) +} + +type FakeJWTService struct { + VerifyProvider func(context.Context, string) (JWTClaims, error) +} + +func (s *FakeJWTService) Verify(ctx context.Context, token string) (JWTClaims, error) { + return s.VerifyProvider(ctx, token) +} + +func (s *FakeJWTService) Init() error { + return nil +} + +func NewFakeJWTService() *FakeJWTService { + return &FakeJWTService{ + VerifyProvider: func(ctx context.Context, token string) (JWTClaims, error) { + return JWTClaims{}, nil + }, + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index edc6751ab2e..955ec21e9c4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -34,6 +34,7 @@ import ( _ "github.com/grafana/grafana/pkg/services/accesscontrol/manager" _ "github.com/grafana/grafana/pkg/services/alerting" _ "github.com/grafana/grafana/pkg/services/auth" + _ "github.com/grafana/grafana/pkg/services/auth/jwt" _ "github.com/grafana/grafana/pkg/services/cleanup" _ "github.com/grafana/grafana/pkg/services/librarypanels" _ "github.com/grafana/grafana/pkg/services/login/loginservice" diff --git a/pkg/services/auth/jwt/auth.go b/pkg/services/auth/jwt/auth.go new file mode 100644 index 00000000000..463d4dcb20c --- /dev/null +++ b/pkg/services/auth/jwt/auth.go @@ -0,0 +1,87 @@ +package jwt + +import ( + "context" + "errors" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/setting" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ServiceName = "AuthService" + +func init() { + registry.Register(®istry.Descriptor{ + Name: ServiceName, + Instance: &AuthService{}, + InitPriority: registry.Medium, + }) +} + +type AuthService struct { + Cfg *setting.Cfg `inject:""` + RemoteCache *remotecache.RemoteCache `inject:""` + + keySet keySet + log log.Logger + expect map[string]interface{} + expectRegistered jwt.Expected +} + +func (s *AuthService) Init() error { + if !s.Cfg.JWTAuthEnabled { + return nil + } + + s.log = log.New("auth.jwt") + + if err := s.initClaimExpectations(); err != nil { + return err + } + if err := s.initKeySet(); err != nil { + return err + } + + return nil +} + +func (s *AuthService) Verify(ctx context.Context, strToken string) (models.JWTClaims, error) { + s.log.Debug("Parsing JSON Web Token") + + token, err := jwt.ParseSigned(strToken) + if err != nil { + return nil, err + } + + keys, err := s.keySet.Key(ctx, token.Headers[0].KeyID) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return nil, errors.New("no keys found") + } + + s.log.Debug("Trying to verify JSON Web Token using a key") + + var claims models.JWTClaims + for _, key := range keys { + if err = token.Claims(key, &claims); err == nil { + break + } + } + if err != nil { + return nil, err + } + + s.log.Debug("Validating JSON Web Token claims") + + if err = s.validateClaims(claims); err != nil { + return nil, err + } + + return claims, nil +} diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go new file mode 100644 index 00000000000..a33803768e6 --- /dev/null +++ b/pkg/services/auth/jwt/auth_test.go @@ -0,0 +1,435 @@ +package jwt + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + jose "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type scenarioContext struct { + ctx context.Context + cfg *setting.Cfg + authJWTSvc *AuthService +} + +type cachingScenarioContext struct { + scenarioContext + reqCount *int +} + +type configureFunc func(*testing.T, *setting.Cfg) +type scenarioFunc func(*testing.T, scenarioContext) +type cachingScenarioFunc func(*testing.T, cachingScenarioContext) + +func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { + subject := "foo-subj" + + key := rsaKeys[0] + unknownKey := rsaKeys[1] + + scenario(t, "verifies a token", func(t *testing.T, sc scenarioContext) { + token := sign(t, key, jwt.Claims{ + Subject: subject, + }) + verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err) + assert.Equal(t, verifiedClaims["sub"], subject) + }, configurePKIXPublicKeyFile) + + scenario(t, "rejects a token signed by unknown key", func(t *testing.T, sc scenarioContext) { + token := sign(t, unknownKey, jwt.Claims{ + Subject: subject, + }) + _, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.Error(t, err) + }, configurePKIXPublicKeyFile) +} + +func TestVerifyUsingJWKSetFile(t *testing.T) { + configure := func(t *testing.T, cfg *setting.Cfg) { + t.Helper() + + file, err := ioutil.TempFile(os.TempDir(), "jwk-*.json") + require.NoError(t, err) + t.Cleanup(func() { + if err := os.Remove(file.Name()); err != nil { + panic(err) + } + }) + + require.NoError(t, json.NewEncoder(file).Encode(jwksPublic)) + require.NoError(t, file.Close()) + + cfg.JWTAuthJWKSetFile = file.Name() + } + + subject := "foo-subj" + + scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}) + verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err) + assert.Equal(t, verifiedClaims["sub"], subject) + }, configure) + + scenario(t, "verifies a token signed with another key from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, &jwKeys[1], jwt.Claims{Subject: subject}) + verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err) + assert.Equal(t, verifiedClaims["sub"], subject) + }, configure) + + scenario(t, "rejects a token signed with a key not from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, jwKeys[2], jwt.Claims{Subject: subject}) + _, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.Error(t, err) + }, configure) +} + +func TestVerifyUsingJWKSetURL(t *testing.T) { + subject := "foo-subj" + + t.Run("should refuse to start with non-https URL", func(t *testing.T) { + var err error + + _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthJWKSetURL = "https://example.com/.well-known/jwks.json" + }) + require.NoError(t, err) + + _, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json" + }) + require.Error(t, err) + }) + + jwkHTTPScenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}) + verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err) + assert.Equal(t, verifiedClaims["sub"], subject) + }) + + jwkHTTPScenario(t, "verifies a token signed with another key from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, &jwKeys[1], jwt.Claims{Subject: subject}) + verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err) + assert.Equal(t, verifiedClaims["sub"], subject) + }) + + jwkHTTPScenario(t, "rejects a token signed with a key not from the set", func(t *testing.T, sc scenarioContext) { + token := sign(t, jwKeys[2], jwt.Claims{Subject: subject}) + _, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.Error(t, err) + }) +} + +func TestCachingJWKHTTPResponse(t *testing.T) { + subject := "foo-subj" + + jwkCachingScenario(t, "caches the jwk response", func(t *testing.T, sc cachingScenarioContext) { + for i := 0; i < 5; i++ { + token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}) + _, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.NoError(t, err, "verify call %d", i+1) + } + + assert.Equal(t, 1, *sc.reqCount) + }) + + jwkCachingScenario(t, "respects TTL setting", func(t *testing.T, sc cachingScenarioContext) { + var err error + + token0 := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}) + token1 := sign(t, &jwKeys[1], jwt.Claims{Subject: subject}) + + _, err = sc.authJWTSvc.Verify(sc.ctx, token0) + require.NoError(t, err) + _, err = sc.authJWTSvc.Verify(sc.ctx, token1) + require.Error(t, err) + + assert.Equal(t, 1, *sc.reqCount) + + time.Sleep(sc.cfg.JWTAuthCacheTTL + time.Millisecond) + + _, err = sc.authJWTSvc.Verify(sc.ctx, token1) + require.NoError(t, err) + _, err = sc.authJWTSvc.Verify(sc.ctx, token0) + require.Error(t, err) + + assert.Equal(t, 2, *sc.reqCount) + }, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthCacheTTL = time.Second + }) + + jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) { + for i := 0; i < 2; i++ { + _, err := sc.authJWTSvc.Verify(sc.ctx, sign(t, &jwKeys[i], jwt.Claims{Subject: subject})) + require.NoError(t, err, "verify call %d", i+1) + } + + assert.Equal(t, 2, *sc.reqCount) + }, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthCacheTTL = 0 + }) +} + +func TestSignatureWithNoneAlgorithm(t *testing.T) { + scenario(t, "rejects a token signed with \"none\" algorithm", func(t *testing.T, sc scenarioContext) { + token := signNone(t, jwt.Claims{Subject: "foo"}) + _, err := sc.authJWTSvc.Verify(sc.ctx, token) + require.Error(t, err) + }, configurePKIXPublicKeyFile) +} + +func TestClaimValidation(t *testing.T) { + key := rsaKeys[0] + + scenario(t, "validates iss field for equality", func(t *testing.T, sc scenarioContext) { + tokenValid := sign(t, key, jwt.Claims{Issuer: "http://foo"}) + tokenInvalid := sign(t, key, jwt.Claims{Issuer: "http://bar"}) + + _, err := sc.authJWTSvc.Verify(sc.ctx, tokenValid) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) + require.Error(t, err) + }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthExpectClaims = `{"iss": "http://foo"}` + }) + + scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) { + var err error + + tokenValid := sign(t, key, jwt.Claims{Subject: "foo"}) + tokenInvalid := sign(t, key, jwt.Claims{Subject: "bar"}) + + _, err = sc.authJWTSvc.Verify(sc.ctx, tokenValid) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid) + require.Error(t, err) + }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthExpectClaims = `{"sub": "foo"}` + }) + + scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) { + var err error + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"bar", "foo"}})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"foo", "bar", "baz"}})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"foo"}})) + require.Error(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"bar", "baz"}})) + require.Error(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}})) + require.Error(t, err) + }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthExpectClaims = `{"aud": ["foo", "bar"]}` + }) + + scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) { + var err error + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo", "my-number": 123})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "bar", "my-number": 123})) + require.Error(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo", "my-number": 100})) + require.Error(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo"})) + require.Error(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-number": 123})) + require.Error(t, err) + }, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthExpectClaims = `{"my-str": "foo", "my-number": 123}` + }) + + scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) { + var err error + + // time.Now should be okay because of default one-minute leeway of go-jose library. + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Expiry: jwt.NewNumericDate(time.Now())})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Expiry: jwt.NewNumericDate(time.Now().Add(-time.Minute - time.Second))})) + require.Error(t, err) + }, configurePKIXPublicKeyFile) + + scenario(t, "validates nbf claim of the token", func(t *testing.T, sc scenarioContext) { + var err error + + // time.Now should be okay because of default one-minute leeway of go-jose library. + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{NotBefore: jwt.NewNumericDate(time.Now())})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{NotBefore: jwt.NewNumericDate(time.Now().Add(time.Minute + time.Second))})) + require.Error(t, err) + }, configurePKIXPublicKeyFile) + + scenario(t, "validates iat claim of the token", func(t *testing.T, sc scenarioContext) { + var err error + + // time.Now should be okay because of default one-minute leeway of go-jose library. + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{IssuedAt: jwt.NewNumericDate(time.Now())})) + require.NoError(t, err) + + _, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{IssuedAt: jwt.NewNumericDate(time.Now().Add(time.Minute + time.Second))})) + require.Error(t, err) + }, configurePKIXPublicKeyFile) +} + +func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configureFunc) { + t.Helper() + t.Run(desc, func(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(jwksPublic); err != nil { + panic(err) + } + })) + t.Cleanup(ts.Close) + + configure := func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthJWKSetURL = ts.URL + } + runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { + keySet := sc.authJWTSvc.keySet.(*keySetHTTP) + keySet.client = ts.Client() + fn(t, sc) + }, append([]configureFunc{configure}, cbs...)...) + runner(t) + }) +} + +func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs ...configureFunc) { + t.Helper() + + t.Run(desc, func(t *testing.T) { + var reqCount int + + // We run a server that each call responds differently. + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if reqCount++; reqCount > 2 { + panic("calling more than two times is not supported") + } + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{jwksPublic.Keys[reqCount-1]}, + } + if err := json.NewEncoder(w).Encode(jwks); err != nil { + panic(err) + } + })) + t.Cleanup(ts.Close) + + configure := func(t *testing.T, cfg *setting.Cfg) { + cfg.JWTAuthJWKSetURL = ts.URL + cfg.JWTAuthCacheTTL = time.Hour + } + runner := scenarioRunner(func(t *testing.T, sc scenarioContext) { + keySet := sc.authJWTSvc.keySet.(*keySetHTTP) + keySet.client = ts.Client() + fn(t, cachingScenarioContext{scenarioContext: sc, reqCount: &reqCount}) + }, append([]configureFunc{configure}, cbs...)...) + + runner(t) + }) +} + +func scenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configureFunc) { + t.Helper() + + t.Run(desc, scenarioRunner(fn, cbs...)) +} + +func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) { + sqlStore := sqlstore.InitTestDB(t) + remoteCacheSvc := &remotecache.RemoteCache{} + cfg := setting.NewCfg() + cfg.JWTAuthEnabled = true + cfg.JWTAuthExpectClaims = "{}" + cfg.RemoteCacheOptions = &setting.RemoteCacheOptions{Name: "database"} + for _, cb := range cbs { + cb(t, cfg) + } + + service := &AuthService{} + + err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{ + { + Name: sqlstore.ServiceName, + Instance: sqlStore, + }, + { + Name: remotecache.ServiceName, + Instance: remoteCacheSvc, + }, + { + Name: ServiceName, + Instance: service, + }, + }) + + return service, err +} + +func scenarioRunner(fn scenarioFunc, cbs ...configureFunc) func(t *testing.T) { + return func(t *testing.T) { + authJWTSvc, err := initAuthService(t, cbs...) + require.NoError(t, err) + + fn(t, scenarioContext{ + ctx: context.Background(), + cfg: authJWTSvc.Cfg, + authJWTSvc: authJWTSvc, + }) + } +} + +func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) { + t.Helper() + + file, err := ioutil.TempFile(os.TempDir(), "public-key-*.pem") + require.NoError(t, err) + t.Cleanup(func() { + if err := os.Remove(file.Name()); err != nil { + panic(err) + } + }) + + blockBytes, err := x509.MarshalPKIXPublicKey(rsaKeys[0].Public()) + require.NoError(t, err) + + require.NoError(t, pem.Encode(file, &pem.Block{ + Type: "PUBLIC KEY", + Bytes: blockBytes, + })) + require.NoError(t, file.Close()) + + cfg.JWTAuthKeyFile = file.Name() +} diff --git a/pkg/services/auth/jwt/key_sets.go b/pkg/services/auth/jwt/key_sets.go new file mode 100644 index 00000000000..c55f13b3772 --- /dev/null +++ b/pkg/services/auth/jwt/key_sets.go @@ -0,0 +1,214 @@ +package jwt + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" + jose "gopkg.in/square/go-jose.v2" +) + +var ErrFailedToParsePemFile = errors.New("failed to parse pem-encoded file") +var ErrKeySetIsNotConfigured = errors.New("key set for jwt verification is not configured") +var ErrKeySetConfigurationAmbiguous = errors.New("key set configuration is ambiguous: you should set either key_file, jwk_set_file or jwk_set_url") +var ErrJWTSetURLMustHaveHTTPSScheme = errors.New("jwt_set_url must have https scheme") + +type keySet interface { + Key(ctx context.Context, kid string) ([]jose.JSONWebKey, error) +} + +type keySetJWKS struct { + jose.JSONWebKeySet +} + +type keySetHTTP struct { + url string + log log.Logger + client *http.Client + cache *remotecache.RemoteCache + cacheKey string + cacheExpiration time.Duration +} + +func (s *AuthService) checkKeySetConfiguration() error { + var count int + if s.Cfg.JWTAuthKeyFile != "" { + count++ + } + if s.Cfg.JWTAuthJWKSetFile != "" { + count++ + } + if s.Cfg.JWTAuthJWKSetURL != "" { + count++ + } + + if count == 0 { + return ErrKeySetIsNotConfigured + } + + if count > 1 { + return ErrKeySetConfigurationAmbiguous + } + + return nil +} + +func (s *AuthService) initKeySet() error { + if err := s.checkKeySetConfiguration(); err != nil { + return err + } + + if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file + file, err := os.Open(keyFilePath) + if err != nil { + return err + } + defer func() { + if err := file.Close(); err != nil { + s.log.Warn("Failed to close file", "path", keyFilePath, "err", err) + } + }() + + data, err := ioutil.ReadAll(file) + if err != nil { + return err + } + block, _ := pem.Decode(data) + if block == nil { + return ErrFailedToParsePemFile + } + + var key interface{} + switch block.Type { + case "PUBLIC KEY": + if key, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + return err + } + case "PRIVATE KEY": + if key, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return err + } + case "RSA PUBLIC KEY": + if key, err = x509.ParsePKCS1PublicKey(block.Bytes); err != nil { + return err + } + case "RSA PRIVATE KEY": + if key, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + return err + } + case "EC PRIVATE KEY": + if key, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + return err + } + default: + return fmt.Errorf("unknown pem block type %q", block.Type) + } + + s.keySet = keySetJWKS{ + jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{{Key: key}}, + }, + } + } else if keyFilePath := s.Cfg.JWTAuthJWKSetFile; keyFilePath != "" { + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file + file, err := os.Open(keyFilePath) + if err != nil { + return err + } + defer func() { + if err := file.Close(); err != nil { + s.log.Warn("Failed to close file", "path", keyFilePath, "err", err) + } + }() + + var jwks jose.JSONWebKeySet + if err := json.NewDecoder(file).Decode(&jwks); err != nil { + return err + } + + s.keySet = keySetJWKS{jwks} + } else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" { + urlParsed, err := url.Parse(urlStr) + if err != nil { + return err + } + if urlParsed.Scheme != "https" { + return ErrJWTSetURLMustHaveHTTPSScheme + } + s.keySet = &keySetHTTP{ + url: urlStr, + log: s.log, + client: &http.Client{}, + cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr), + cacheExpiration: s.Cfg.JWTAuthCacheTTL, + cache: s.RemoteCache, + } + } + + return nil +} + +func (ks keySetJWKS) Key(ctx context.Context, keyID string) ([]jose.JSONWebKey, error) { + return ks.JSONWebKeySet.Key(keyID), nil +} + +func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) { + var jwks keySetJWKS + + if ks.cacheExpiration > 0 { + if val, err := ks.cache.Get(ks.cacheKey); err == nil { + err := json.Unmarshal(val.([]byte), &jwks) + return jwks, err + } + } + + ks.log.Debug("Getting key set from endpoint", "url", ks.url) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ks.url, nil) + if err != nil { + return jwks, err + } + + resp, err := ks.client.Do(req) + if err != nil { + return jwks, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + ks.log.Warn("Failed to close response body", "err", err) + } + }() + + var jsonBuf bytes.Buffer + if err := json.NewDecoder(io.TeeReader(resp.Body, &jsonBuf)).Decode(&jwks); err != nil { + return jwks, err + } + + if ks.cacheExpiration > 0 { + err = ks.cache.Set(ks.cacheKey, jsonBuf.Bytes(), ks.cacheExpiration) + } + return jwks, err +} + +func (ks keySetHTTP) Key(ctx context.Context, kid string) ([]jose.JSONWebKey, error) { + jwks, err := ks.getJWKS(ctx) + if err != nil { + return nil, err + } + return jwks.Key(ctx, kid) +} diff --git a/pkg/services/auth/jwt/rsa_keys_test.go b/pkg/services/auth/jwt/rsa_keys_test.go new file mode 100644 index 00000000000..58f2694a567 --- /dev/null +++ b/pkg/services/auth/jwt/rsa_keys_test.go @@ -0,0 +1,123 @@ +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + + jose "gopkg.in/square/go-jose.v2" +) + +var rsaKeys [3]*rsa.PrivateKey +var jwKeys [3]jose.JSONWebKey +var jwksPublic jose.JSONWebKeySet + +const rsaKeysPEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0vKKKQzRHxtnvgvScOvZW2lIBiZ0YBN8ZwAfQdpEQy2w7qAT +WVCr/N/Tj6971gzbwqHL+VIw67SA0o2Ofb/96j2OXsS0mEo/8d2Q3rtOmzAobRBD +fkfVQW3TsbT8Lm+nM9PJkg8+5ZdTYt4ABQRNPFj7jei+udb6mo3aJIFK108K5+gi +gDsgoBS9iZ2CsSymUXd3Sb+WuLx6Cgzx7hjQvsNG4MnQDo1DOFQ+soQqKVRpzH9C +CI574y0fN6TZbi9HehZxgdJgPEiJ9xRuO5pP74fcxEQIcI7lCmBk+t/c3jr4f7Zp +ubLQofvfmF7zSMDveFsh7f80T0SW4/ll4pOJjwIDAQABAoIBAAWiAFp0QylHfA+x +FR96zMUKHKg9YqImIw5FDJCfmW8Jy02z7JBX/R+1glq13uKqWTvrQh0YOsIwgbgd +m450D/2vQxv4uLHQWcDFn9ayvbibIpk28/ZtSJ6EpkB6irlateZGY32I9q7+yXU8 +ZFe986oG3kC91En2GZ8C5q5O3Ya+Qyf/uT533cB4u3kMfsBzF38axJgw/z1tW3OJ +XaJHrcYb8yBS0nlrfDQeEvE3JxwtFrtz+oPZ3uqNyUX7Ti0s3HTzG0xs+ctg7Wm9 +fRaogbU6wiDMj74ixszdZ7bdgwuETPw85XIcelrlNnCUDCDjmiA83XaxL+0yhOlx +Nb4F2fECgYEA9gK7zaswBsXk9BA73rWbDmO5cJU1dYAkZnWW4kIwjQoccTXAH02l +tvQ9bEyhxp8xf5+H/OLuR8Pc0sP7FCu/UojaQ+4js3DKC5tccMWAmxDrqySilXqU +P3uENNXVp4EyQ2BJQ/t3vuuXxXaAg9FnilK/MZDxdoG9lLQpzHq4BVMCgYEA24NT +WqfTMAnOE4bYKUHPCpaEbwNNEMZnYZDUruHXSZaOqb4QAqNcVsiVWauAn1KEyxV0 +ybTt6lULRu1JyLM31kAAKiJqxCYV7gXdBoJymDx1FU8SmE7YR2cKVGJM7YhuKAZI +VGg51KgeRldWlDF2y9zoDRmqwwNLmb6RZpxJh1UCgYAGGodCUQ79/Ab0LzrtCaBx +OPQu4OTUp3s/t4co0e+WcDvIa0b6/9gus9yaRUR2QxjdS735/j9fNHLUH9yo4XT7 +vT19Ffl4yEGbDB29BolsT30pX91QzBvFf3EGRo/oegIfPdJTh3evGvVHBuulWZqy +Cd+IgUocYJetitLGqfzK1QKBgQCtm9e4wzKLs7WATA1508pjnVdwVTQGKGRrDZio +F4WldaWvKdqPu5O0Lz+vg6xeVW0hEP8k6CuiQVCB7/mC+fsXP9bhfAbkyxpc/dYo +59KqBGa1S6xxOSpkjmHlCzm8Q0Kb9RwPZb8XKT+IclrPKa/C3BvLAJnFUj3ggo+M +j963YQKBgQCDByqxFdPTzeHpZnLHHmmYAN9HbNoxLDX4JCB2iT20lQu1Agp2DgBA +CKlDUOMt6UUIGYKD+Hn8RDUQN3Um8nDaa5RFsy7F9Og57M/VLdvRb0mBvTdgiKSB +wcPDty9QlZ5dfLsagYG5rcFGCDt3FZvpPyaeYcxK9/QC5VvAHj6gbA== +-----END RSA PRIVATE KEY----- +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEApuLHCQ7uIeWX+fszaToHnBLufpa32EsaQ4pLCKdI/Zflc4VR +eyArQvMm1jUXggCvyAzLCgOLYRcXIOPAbzanLd1Fh+BmqBs0jacLfxD+vN2OEqpw +e7Li3YTN3d032wO9bL2XmUEgoZkUcmkJGoTT34lMDyTgIexWv/4WXwQo6ZdukGqG +OWlFZJwOv/gIZkCk3uUIgvtkp6UAxzIl9EsH5MdEwaHH3hlvao0IFpA/Gfg3VKY6 +SnqtIH11Ck2XQD2c0kE7yPkRh71gdTO1tXn1Ch/sWhqprAjqXqNOZIFir4dMKfc+ +I25hDxFtp/eUbKExe+/tsuS1CFEDLJJwdVmDMQIDAQABAoIBABBdrv0xbKMAXA4U +127bVQG2TZM2fqMEgnfbKQ6ZMSxFaCgTC/GSLwvqwoVBQaPrI1HwBz1dKZ8E36zH +CQkfB/gUegwgMpEL0fSOTC9S4FhvNc6Yzl0jJuJocrPuTNr6m+n9Ec/itiuC0qGB +sXXbTtfeJApcKGrLPZqodVMuGkEGAyqbiPdp6eYC3C+lkitDcOHCG6Z4WzA6HFr/ +Y8TkozUcbgqX18zVUGI7147mYNdQW2Ap20hPqfO1A9MtbqEx05NxQiTsbf8U+QGo +NJ24es3c3fFVZambp/RXfLQuPBciTKmQoD6fWWOybB1a5/Owbb7+aiPAySrSRPHE +Um3wnc0CgYEAxefN703MWHv1fkQGa8fNNw/DoKh5w9nLrNdrfnWGWDvsy9giW1Xq +QYL+Dq+17vQ+TdWOeUWeCG679Oj/bvRiZy15ikR1/KmM3v4KkG3/P+ugLZRdd32X +Ldpg1f3MNtLVXgAbTtd6oy7FVcPThP54s2A5Ab/2yLCq+mFDn5ahsecCgYEA19/p +C0WEvChPzXJanb2npJHy8gHwkuEn0D9zNzKX6ToOKwoqIMjTt2wmbHJd42sbCOc0 +S7UKcIof11q7lV7/TnLB+V4C8f6dKe34lF+dGfXDljcqu+uNwXS1JQvQST5OwcGb +foXDtDH10yZroEaCnXBuQ0szMbSZVlh2M67CLycCgYA/tSxU8b1rapQPjoRmo84L +AJcgG7v+8RigzkP7VIfn1XqX8D63GkQrzKhOQAAYKSX5Vlrj7SY/Xq5A29SGekNH +JZtviDRXHpmLm0n5Tn+Rqx9ILO+drJ9DEn6DxIy9xUcMWIpx6em/qCm8PyrTMDvY +uov/ZTVjS4Puz+q97/ajVwKBgCKIm0tGT7mZ6UpAZOafFFZrUqYMUWPtyOSzgcbu +vQZ+Vw3jjmG4PsY08uCeWw6qb6S499C0oXrnXbihtyhqDgWKriUqOOZliNbQTtfN +g+BHRIafRKLTR9YOyXunrCZLZWXxhuJym6AT7fNdThJRtUtiVQFG4mWtMmpxtFcI +OeA5AoGBALI2NDwPF2MCPIQRpkL9aH3RwnBXNFqKseww5IDWbbjL8VaVPPZeDrDg +7gpfPCor+Acd+Rj2qSWgQG2dkG+tv5+zT954fGcEQsV9L/PXl1KrQmgEO3bHFeCn +FNm+zIsFGY58CHyj8sCA9Sq23JlSx5y2E1SF2Skyp2+0n7tdLaOd +-----END RSA PRIVATE KEY----- +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAw45ZGszAxk7hg4sosECMtQz/z6nt2OMkgi0uZCm+2h6js/1l +wD4ZBjeS9eR6fE3o2pF/jK9Xd2MRHREeaU6ha1d1HmCyTjWnMNgZP6ZZd9xmOznr +bZx4iyXw8EQ3FC+4lg46XFUeYJLwRZKlt2Ks+s0kWLpE6/KP/TK2LOm710k/9W8/ +jlmUkOiw4uYEX8HPSgTeWKZbaVxppTG7CzDD722Q9ZCXGwTJ8uWN5VFdvRXEeHAt +XmsHJmHEjR+R1fJvERgEdRqnEaIsMkRmzRCYr6MKj2Xb+Ro6hU3/7grVzHEKX9jT +DAIX1rCkL0yF5Ka0cLbylUM1ZwpXyDpblQeOiQIDAQABAoIBADkhOvLTYnYM0WEm +pGppUTILbCh00mGMajwFiwoEEBeU3+pTWwiAm7rvPWXMq+PotuAzpXmqN/lO3c8K +E0JckFfVoweO5Eho8EEawLWRmY2ku9ENqLPLBIRSP0NSCm1BS8G6wl37F/bKtpr8 +rqEWmMZka/vn3v63TE2CJSqV1iScZLdAJQT8kQNOhDYKXK+yXkcKSXgRSc0KLiYe +GL03cksYp8cBBQEtpGFw4RgK2f9sTuM5L26eOhhDEh46ByKb7cFhTto4VX/Gs61d +92Etkz9TNAEMXNT8DnPdR58/n/9DKf+tGRJD1YktcNOKMzGTxY64QEjcpxFFvRe1 +HwyyRQECgYEA0u4k1J0253lhrKAN0A3vmhrUR1jGMMCrtB6WbGZUNbdxiDqGYp+D +x7FJQm2hAth1hSw9js+wkcc18Nn+zQQ+cfNNP69+3W/eLi1JktlZUdDRoN+uioZe +A21FyMY1HbfTcRcnfTKv5kbCYCJBi0z1D5x+YWCdmnFFzNwfG7cu1tkCgYEA7Vc8 +xpkUeejefustUGXWMVCOXeafBnIwyWrzyN0SC7zhA1ws/v78MBPPxAjKghD1vKSh +1H+frio7wpFMLe7WZpGfqFoCllARBCR1smcDnoSqRY4EM+FhmDHXcfQ00sHOpztj +KKnlRs75mrVQ2HkPwpYMdNgpM/piIK8oqe1khzECgYAqND4oUICg1hemC6xX2cH8 +Sqv4zplxPcvdUVV1wQ/OY7MSt+sVpqceeKmY4giaYic5iz2R6pqAwKRZWbTy3ouE +D1OAj6PJuM1y3drfyB9oEGkxUDBDRVlgRCf3YTlVheeHtENReKfbYoMX6yLENZS/ +F+ftogBG261ErTKIQCHeGQKBgQCMMdO8m//0YxHKdrC1pPH4/1SZMvkMnbcjwwFt +zOgz9sYTbgdGOOhOneVELs0wN0Rwwe61zw1Lm7bhH2KYX1RWEf71OvX8RB9JCyBa +2W7R3BuYKmNhIei8NfTFYzMwqzqenf3crz63rNrT//ZZaGlez7Nb8bOk+GmuVMj4 +VznigQKBgGHaNPvpmcMFMFeocDM9pGQ/KtzgVUU9mfd4yqhTyQ/pZ+XDPdz0DBQK +lzlCHD844HY1BjQurECe9QgqUB/slaMC3hl7l6bPNHQk7/plNsER7E7hzN6f6PZM +kXxSnDcVQGY0cWZ0FROyYbBp3nBVA5VT5HYYGfazhsisIHP+3zoG +-----END RSA PRIVATE KEY----- +` + +func init() { + data := []byte(rsaKeysPEM) + for i := 0; i < len(rsaKeys); i++ { + var block *pem.Block + block, data = pem.Decode(data) + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + panic(err) + } + keyID := fmt.Sprintf("key-id-%02d", i) + + rsaKeys[i] = key + jwKeys[i].KeyID = keyID + jwKeys[i].Key = key + + // Last key is reserved to test as an unknown key. + if i < len(rsaKeys)-1 { + jwksPublic.Keys = append(jwksPublic.Keys, jose.JSONWebKey{ + KeyID: keyID, + Key: key.Public(), + }) + } + } +} diff --git a/pkg/services/auth/jwt/signing_test.go b/pkg/services/auth/jwt/signing_test.go new file mode 100644 index 00000000000..71327b04210 --- /dev/null +++ b/pkg/services/auth/jwt/signing_test.go @@ -0,0 +1,43 @@ +package jwt + +import ( + "testing" + + "github.com/stretchr/testify/require" + jose "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type noneSigner struct{} + +func sign(t *testing.T, key interface{}, claims interface{}) string { + t.Helper() + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.PS512, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) + require.NoError(t, err) + token, err := jwt.Signed(sig).Claims(claims).CompactSerialize() + require.NoError(t, err) + return token +} + +func (s noneSigner) Public() *jose.JSONWebKey { + return nil +} + +func (s noneSigner) Algs() []jose.SignatureAlgorithm { + return []jose.SignatureAlgorithm{"none"} +} + +func (s noneSigner) SignPayload(payload []byte, alg jose.SignatureAlgorithm) ([]byte, error) { + return nil, nil +} + +func signNone(t *testing.T, claims interface{}) string { + t.Helper() + + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: "none", Key: noneSigner{}}, (&jose.SignerOptions{}).WithType("JWT")) + require.NoError(t, err) + token, err := jwt.Signed(sig).Claims(claims).CompactSerialize() + require.NoError(t, err) + return token +} diff --git a/pkg/services/auth/jwt/validation.go b/pkg/services/auth/jwt/validation.go new file mode 100644 index 00000000000..ab7c46a052a --- /dev/null +++ b/pkg/services/auth/jwt/validation.go @@ -0,0 +1,137 @@ +package jwt + +import ( + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/grafana/grafana/pkg/models" + "gopkg.in/square/go-jose.v2/jwt" +) + +func (s *AuthService) initClaimExpectations() error { + if err := json.Unmarshal([]byte(s.Cfg.JWTAuthExpectClaims), &s.expect); err != nil { + return err + } + + for key, value := range s.expect { + switch key { + case "iss": + if stringValue, ok := value.(string); ok { + s.expectRegistered.Issuer = stringValue + } else { + return fmt.Errorf("%q expectation has invalid type %T, string expected", key, value) + } + delete(s.expect, key) + case "sub": + if stringValue, ok := value.(string); ok { + s.expectRegistered.Subject = stringValue + } else { + return fmt.Errorf("%q expectation has invalid type %T, string expected", key, value) + } + delete(s.expect, key) + case "aud": + switch value := value.(type) { + case []interface{}: + for _, val := range value { + if val, ok := val.(string); ok { + s.expectRegistered.Audience = append(s.expectRegistered.Audience, val) + } else { + return fmt.Errorf("%q expectation contains value with invalid type %T, string expected", key, val) + } + } + case string: + s.expectRegistered.Audience = []string{value} + default: + return fmt.Errorf("%q expectation has invalid type %T, array or string expected", key, value) + } + delete(s.expect, key) + } + } + + return nil +} + +func (s *AuthService) validateClaims(claims models.JWTClaims) error { + var registeredClaims jwt.Claims + for key, value := range claims { + switch key { + case "iss": + if stringValue, ok := value.(string); ok { + registeredClaims.Issuer = stringValue + } else { + return fmt.Errorf("%q claim has invalid type %T, string expected", key, value) + } + case "sub": + if stringValue, ok := value.(string); ok { + registeredClaims.Subject = stringValue + } else { + return fmt.Errorf("%q claim has invalid type %T, string expected", key, value) + } + case "aud": + switch value := value.(type) { + case []interface{}: + for _, val := range value { + if val, ok := val.(string); ok { + registeredClaims.Audience = append(registeredClaims.Audience, val) + } else { + return fmt.Errorf("%q claim contains value with invalid type %T, string expected", key, val) + } + } + case string: + registeredClaims.Audience = []string{value} + default: + return fmt.Errorf("%q claim has invalid type %T, array or string expected", key, value) + } + case "exp": + if value == nil { + continue + } + if floatValue, ok := value.(float64); ok { + out := jwt.NumericDate(floatValue) + registeredClaims.Expiry = &out + } else { + return fmt.Errorf("%q claim has invalid type %T, number expected", key, value) + } + case "nbf": + if value == nil { + continue + } + if floatValue, ok := value.(float64); ok { + out := jwt.NumericDate(floatValue) + registeredClaims.NotBefore = &out + } else { + return fmt.Errorf("%q claim has invalid type %T, number expected", key, value) + } + case "iat": + if value == nil { + continue + } + if floatValue, ok := value.(float64); ok { + out := jwt.NumericDate(floatValue) + registeredClaims.IssuedAt = &out + } else { + return fmt.Errorf("%q claim has invalid type %T, number expected", key, value) + } + } + } + + expectRegistered := s.expectRegistered + expectRegistered.Time = time.Now() + if err := registeredClaims.Validate(expectRegistered); err != nil { + return err + } + + for key, expected := range s.expect { + value, ok := claims[key] + if !ok { + return fmt.Errorf("%q claim is missing", key) + } + if !reflect.DeepEqual(expected, value) { + return fmt.Errorf("%q claim mismatch", key) + } + } + + return nil +} diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go new file mode 100644 index 00000000000..c6827dba4da --- /dev/null +++ b/pkg/services/contexthandler/auth_jwt.go @@ -0,0 +1,64 @@ +package contexthandler + +import ( + "errors" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/login" + "github.com/grafana/grafana/pkg/models" +) + +const InvalidJWT = "Invalid JWT" + +func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool { + if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" { + return false + } + + jwtToken := ctx.Req.Header.Get(h.Cfg.JWTAuthHeaderName) + if jwtToken == "" { + return false + } + + claims, err := h.JWTAuthService.Verify(ctx.Req.Context(), jwtToken) + if err != nil { + ctx.Logger.Debug("Failed to verify JWT", "error", err) + ctx.JsonApiErr(401, InvalidJWT, err) + return true + } + + query := models.GetSignedInUserQuery{OrgId: orgId} + + if key := h.Cfg.JWTAuthUsernameClaim; key != "" { + query.Login, _ = claims[key].(string) + } + if key := h.Cfg.JWTAuthEmailClaim; key != "" { + query.Email, _ = claims[key].(string) + } + + if query.Login == "" && query.Email == "" { + ctx.Logger.Debug("Failed to get an authentication claim from JWT") + ctx.JsonApiErr(401, InvalidJWT, err) + return true + } + + if err := bus.Dispatch(&query); err != nil { + if errors.Is(err, models.ErrUserNotFound) { + ctx.Logger.Debug( + "Failed to find user using JWT claims", + "email_claim", query.Email, + "username_claim", query.Login, + ) + err = login.ErrInvalidCredentials + } else { + ctx.Logger.Error("Failed to get signed in user", "error", err) + } + ctx.JsonApiErr(401, InvalidJWT, err) + return true + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + + return true +} diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index b7a86d50eea..17f47c35780 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -107,6 +108,7 @@ func getContextHandler(t *testing.T) *ContextHandler { cfg.AuthProxyHeaderProperty = "username" userAuthTokenSvc := auth.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} + authJWTSvc := models.NewFakeJWTService() svc := &ContextHandler{} err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{ @@ -126,6 +128,10 @@ func getContextHandler(t *testing.T) *ContextHandler { Name: rendering.ServiceName, Instance: renderSvc, }, + { + Name: jwt.ServiceName, + Instance: authJWTSvc, + }, { Name: ServiceName, Instance: svc, diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index d00e303c81b..b226eb9b6f7 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -44,6 +44,7 @@ func init() { type ContextHandler struct { Cfg *setting.Cfg `inject:""` AuthTokenService models.UserTokenService `inject:""` + JWTAuthService models.JWTService `inject:""` RemoteCache *remotecache.RemoteCache `inject:""` RenderService rendering.Service `inject:""` SQLStore *sqlstore.SQLStore `inject:""` @@ -92,6 +93,7 @@ func (h *ContextHandler) Middleware(c *macaron.Context) { case h.initContextWithBasicAuth(ctx, orgID): case h.initContextWithAuthProxy(ctx, orgID): case h.initContextWithToken(ctx, orgID): + case h.initContextWithJWT(ctx, orgID): case h.initContextWithAnonymousUser(ctx): } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 13e55b9d486..e48ab3e8ffa 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -301,6 +301,17 @@ type Cfg struct { SAMLEnabled bool SAMLSingleLogoutEnabled bool + // JWT Auth + JWTAuthEnabled bool + JWTAuthHeaderName string + JWTAuthEmailClaim string + JWTAuthUsernameClaim string + JWTAuthExpectClaims string + JWTAuthJWKSetURL string + JWTAuthCacheTTL time.Duration + JWTAuthKeyFile string + JWTAuthJWKSetFile string + // Dataproxy SendUserHeader bool @@ -1179,6 +1190,18 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) cfg.BasicAuthEnabled = BasicAuthEnabled + // JWT auth + authJWT := iniFile.Section("auth.jwt") + cfg.JWTAuthEnabled = authJWT.Key("enabled").MustBool(false) + cfg.JWTAuthHeaderName = valueAsString(authJWT, "header_name", "") + cfg.JWTAuthEmailClaim = valueAsString(authJWT, "email_claim", "") + cfg.JWTAuthUsernameClaim = valueAsString(authJWT, "username_claim", "") + cfg.JWTAuthExpectClaims = valueAsString(authJWT, "expect_claims", "{}") + cfg.JWTAuthJWKSetURL = valueAsString(authJWT, "jwk_set_url", "") + cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60) + cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "") + cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "") + authProxy := iniFile.Section("auth.proxy") AuthProxyEnabled = authProxy.Key("enabled").MustBool(false) cfg.AuthProxyEnabled = AuthProxyEnabled