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