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:
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):
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user