mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Auth: support JWT Authentication (#29995)
This commit is contained in:
parent
1446d094b8
commit
39a3b0d0b0
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
92
docs/sources/auth/jwt.md
Normal 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"}
|
||||
```
|
@ -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]
|
||||
|
@ -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,
|
||||
|
126
pkg/middleware/middleware_jwt_auth_test.go
Normal file
126
pkg/middleware/middleware_jwt_auth_test.go
Normal 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)
|
||||
}
|
@ -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,
|
||||
|
@ -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
31
pkg/models/jwt.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
87
pkg/services/auth/jwt/auth.go
Normal file
87
pkg/services/auth/jwt/auth.go
Normal 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(®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
|
||||
}
|
435
pkg/services/auth/jwt/auth_test.go
Normal file
435
pkg/services/auth/jwt/auth_test.go
Normal 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()
|
||||
}
|
214
pkg/services/auth/jwt/key_sets.go
Normal file
214
pkg/services/auth/jwt/key_sets.go
Normal 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)
|
||||
}
|
123
pkg/services/auth/jwt/rsa_keys_test.go
Normal file
123
pkg/services/auth/jwt/rsa_keys_test.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
43
pkg/services/auth/jwt/signing_test.go
Normal file
43
pkg/services/auth/jwt/signing_test.go
Normal 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
|
||||
}
|
137
pkg/services/auth/jwt/validation.go
Normal file
137
pkg/services/auth/jwt/validation.go
Normal 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
|
||||
}
|
64
pkg/services/contexthandler/auth_jwt.go
Normal file
64
pkg/services/contexthandler/auth_jwt.go
Normal 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
|
||||
}
|
@ -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,
|
||||
|
@ -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):
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user