Auth: support JWT Authentication (#29995)

This commit is contained in:
Vladimir Kochnev 2021-03-31 15:40:44 +00:00 committed by GitHub
parent 1446d094b8
commit 39a3b0d0b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1444 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -799,6 +799,12 @@ Use the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/A
<hr />
## [auth.jwt]
Refer to [JWT authentication]({{< relref "../auth/jwt.md" >}}) for more information.
<hr />
## [smtp]
Email server settings.

View File

@ -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]

92
docs/sources/auth/jwt.md Normal file
View File

@ -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"}
```

View File

@ -18,6 +18,7 @@ Provider | Support | Role mapping | Team sync<br> *(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]

View File

@ -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,

View File

@ -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)
}

View File

@ -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,

View File

@ -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{

31
pkg/models/jwt.go Normal file
View File

@ -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
},
}
}

View File

@ -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"

View File

@ -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(&registry.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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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(),
})
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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):
}

View File

@ -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