Auth: support JWT Authentication (#29995)

This commit is contained in:
Vladimir Kochnev
2021-03-31 15:40:44 +00:00
committed by GitHub
parent 1446d094b8
commit 39a3b0d0b0
22 changed files with 1444 additions and 4 deletions

View File

@@ -0,0 +1,87 @@
package jwt
import (
"context"
"errors"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/setting"
"gopkg.in/square/go-jose.v2/jwt"
)
const ServiceName = "AuthService"
func init() {
registry.Register(&registry.Descriptor{
Name: ServiceName,
Instance: &AuthService{},
InitPriority: registry.Medium,
})
}
type AuthService struct {
Cfg *setting.Cfg `inject:""`
RemoteCache *remotecache.RemoteCache `inject:""`
keySet keySet
log log.Logger
expect map[string]interface{}
expectRegistered jwt.Expected
}
func (s *AuthService) Init() error {
if !s.Cfg.JWTAuthEnabled {
return nil
}
s.log = log.New("auth.jwt")
if err := s.initClaimExpectations(); err != nil {
return err
}
if err := s.initKeySet(); err != nil {
return err
}
return nil
}
func (s *AuthService) Verify(ctx context.Context, strToken string) (models.JWTClaims, error) {
s.log.Debug("Parsing JSON Web Token")
token, err := jwt.ParseSigned(strToken)
if err != nil {
return nil, err
}
keys, err := s.keySet.Key(ctx, token.Headers[0].KeyID)
if err != nil {
return nil, err
}
if len(keys) == 0 {
return nil, errors.New("no keys found")
}
s.log.Debug("Trying to verify JSON Web Token using a key")
var claims models.JWTClaims
for _, key := range keys {
if err = token.Claims(key, &claims); err == nil {
break
}
}
if err != nil {
return nil, err
}
s.log.Debug("Validating JSON Web Token claims")
if err = s.validateClaims(claims); err != nil {
return nil, err
}
return claims, nil
}

View File

@@ -0,0 +1,435 @@
package jwt
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
jose "gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)
type scenarioContext struct {
ctx context.Context
cfg *setting.Cfg
authJWTSvc *AuthService
}
type cachingScenarioContext struct {
scenarioContext
reqCount *int
}
type configureFunc func(*testing.T, *setting.Cfg)
type scenarioFunc func(*testing.T, scenarioContext)
type cachingScenarioFunc func(*testing.T, cachingScenarioContext)
func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) {
subject := "foo-subj"
key := rsaKeys[0]
unknownKey := rsaKeys[1]
scenario(t, "verifies a token", func(t *testing.T, sc scenarioContext) {
token := sign(t, key, jwt.Claims{
Subject: subject,
})
verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err)
assert.Equal(t, verifiedClaims["sub"], subject)
}, configurePKIXPublicKeyFile)
scenario(t, "rejects a token signed by unknown key", func(t *testing.T, sc scenarioContext) {
token := sign(t, unknownKey, jwt.Claims{
Subject: subject,
})
_, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.Error(t, err)
}, configurePKIXPublicKeyFile)
}
func TestVerifyUsingJWKSetFile(t *testing.T) {
configure := func(t *testing.T, cfg *setting.Cfg) {
t.Helper()
file, err := ioutil.TempFile(os.TempDir(), "jwk-*.json")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
require.NoError(t, json.NewEncoder(file).Encode(jwksPublic))
require.NoError(t, file.Close())
cfg.JWTAuthJWKSetFile = file.Name()
}
subject := "foo-subj"
scenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject})
verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err)
assert.Equal(t, verifiedClaims["sub"], subject)
}, configure)
scenario(t, "verifies a token signed with another key from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, &jwKeys[1], jwt.Claims{Subject: subject})
verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err)
assert.Equal(t, verifiedClaims["sub"], subject)
}, configure)
scenario(t, "rejects a token signed with a key not from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, jwKeys[2], jwt.Claims{Subject: subject})
_, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.Error(t, err)
}, configure)
}
func TestVerifyUsingJWKSetURL(t *testing.T) {
subject := "foo-subj"
t.Run("should refuse to start with non-https URL", func(t *testing.T) {
var err error
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthJWKSetURL = "https://example.com/.well-known/jwks.json"
})
require.NoError(t, err)
_, err = initAuthService(t, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthJWKSetURL = "http://example.com/.well-known/jwks.json"
})
require.Error(t, err)
})
jwkHTTPScenario(t, "verifies a token signed with a key from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject})
verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err)
assert.Equal(t, verifiedClaims["sub"], subject)
})
jwkHTTPScenario(t, "verifies a token signed with another key from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, &jwKeys[1], jwt.Claims{Subject: subject})
verifiedClaims, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err)
assert.Equal(t, verifiedClaims["sub"], subject)
})
jwkHTTPScenario(t, "rejects a token signed with a key not from the set", func(t *testing.T, sc scenarioContext) {
token := sign(t, jwKeys[2], jwt.Claims{Subject: subject})
_, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.Error(t, err)
})
}
func TestCachingJWKHTTPResponse(t *testing.T) {
subject := "foo-subj"
jwkCachingScenario(t, "caches the jwk response", func(t *testing.T, sc cachingScenarioContext) {
for i := 0; i < 5; i++ {
token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject})
_, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.NoError(t, err, "verify call %d", i+1)
}
assert.Equal(t, 1, *sc.reqCount)
})
jwkCachingScenario(t, "respects TTL setting", func(t *testing.T, sc cachingScenarioContext) {
var err error
token0 := sign(t, &jwKeys[0], jwt.Claims{Subject: subject})
token1 := sign(t, &jwKeys[1], jwt.Claims{Subject: subject})
_, err = sc.authJWTSvc.Verify(sc.ctx, token0)
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, token1)
require.Error(t, err)
assert.Equal(t, 1, *sc.reqCount)
time.Sleep(sc.cfg.JWTAuthCacheTTL + time.Millisecond)
_, err = sc.authJWTSvc.Verify(sc.ctx, token1)
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, token0)
require.Error(t, err)
assert.Equal(t, 2, *sc.reqCount)
}, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthCacheTTL = time.Second
})
jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) {
for i := 0; i < 2; i++ {
_, err := sc.authJWTSvc.Verify(sc.ctx, sign(t, &jwKeys[i], jwt.Claims{Subject: subject}))
require.NoError(t, err, "verify call %d", i+1)
}
assert.Equal(t, 2, *sc.reqCount)
}, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthCacheTTL = 0
})
}
func TestSignatureWithNoneAlgorithm(t *testing.T) {
scenario(t, "rejects a token signed with \"none\" algorithm", func(t *testing.T, sc scenarioContext) {
token := signNone(t, jwt.Claims{Subject: "foo"})
_, err := sc.authJWTSvc.Verify(sc.ctx, token)
require.Error(t, err)
}, configurePKIXPublicKeyFile)
}
func TestClaimValidation(t *testing.T) {
key := rsaKeys[0]
scenario(t, "validates iss field for equality", func(t *testing.T, sc scenarioContext) {
tokenValid := sign(t, key, jwt.Claims{Issuer: "http://foo"})
tokenInvalid := sign(t, key, jwt.Claims{Issuer: "http://bar"})
_, err := sc.authJWTSvc.Verify(sc.ctx, tokenValid)
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
require.Error(t, err)
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthExpectClaims = `{"iss": "http://foo"}`
})
scenario(t, "validates sub field for equality", func(t *testing.T, sc scenarioContext) {
var err error
tokenValid := sign(t, key, jwt.Claims{Subject: "foo"})
tokenInvalid := sign(t, key, jwt.Claims{Subject: "bar"})
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenValid)
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, tokenInvalid)
require.Error(t, err)
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthExpectClaims = `{"sub": "foo"}`
})
scenario(t, "validates aud field for inclusion", func(t *testing.T, sc scenarioContext) {
var err error
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"bar", "foo"}}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"foo", "bar", "baz"}}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"foo"}}))
require.Error(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"bar", "baz"}}))
require.Error(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Audience: []string{"baz"}}))
require.Error(t, err)
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthExpectClaims = `{"aud": ["foo", "bar"]}`
})
scenario(t, "validates non-registered (custom) claims for equality", func(t *testing.T, sc scenarioContext) {
var err error
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo", "my-number": 123}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "bar", "my-number": 123}))
require.Error(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo", "my-number": 100}))
require.Error(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-str": "foo"}))
require.Error(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, map[string]interface{}{"my-number": 123}))
require.Error(t, err)
}, configurePKIXPublicKeyFile, func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthExpectClaims = `{"my-str": "foo", "my-number": 123}`
})
scenario(t, "validates exp claim of the token", func(t *testing.T, sc scenarioContext) {
var err error
// time.Now should be okay because of default one-minute leeway of go-jose library.
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Expiry: jwt.NewNumericDate(time.Now())}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{Expiry: jwt.NewNumericDate(time.Now().Add(-time.Minute - time.Second))}))
require.Error(t, err)
}, configurePKIXPublicKeyFile)
scenario(t, "validates nbf claim of the token", func(t *testing.T, sc scenarioContext) {
var err error
// time.Now should be okay because of default one-minute leeway of go-jose library.
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{NotBefore: jwt.NewNumericDate(time.Now())}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{NotBefore: jwt.NewNumericDate(time.Now().Add(time.Minute + time.Second))}))
require.Error(t, err)
}, configurePKIXPublicKeyFile)
scenario(t, "validates iat claim of the token", func(t *testing.T, sc scenarioContext) {
var err error
// time.Now should be okay because of default one-minute leeway of go-jose library.
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{IssuedAt: jwt.NewNumericDate(time.Now())}))
require.NoError(t, err)
_, err = sc.authJWTSvc.Verify(sc.ctx, sign(t, key, jwt.Claims{IssuedAt: jwt.NewNumericDate(time.Now().Add(time.Minute + time.Second))}))
require.Error(t, err)
}, configurePKIXPublicKeyFile)
}
func jwkHTTPScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configureFunc) {
t.Helper()
t.Run(desc, func(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(jwksPublic); err != nil {
panic(err)
}
}))
t.Cleanup(ts.Close)
configure := func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthJWKSetURL = ts.URL
}
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
keySet.client = ts.Client()
fn(t, sc)
}, append([]configureFunc{configure}, cbs...)...)
runner(t)
})
}
func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs ...configureFunc) {
t.Helper()
t.Run(desc, func(t *testing.T) {
var reqCount int
// We run a server that each call responds differently.
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if reqCount++; reqCount > 2 {
panic("calling more than two times is not supported")
}
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{jwksPublic.Keys[reqCount-1]},
}
if err := json.NewEncoder(w).Encode(jwks); err != nil {
panic(err)
}
}))
t.Cleanup(ts.Close)
configure := func(t *testing.T, cfg *setting.Cfg) {
cfg.JWTAuthJWKSetURL = ts.URL
cfg.JWTAuthCacheTTL = time.Hour
}
runner := scenarioRunner(func(t *testing.T, sc scenarioContext) {
keySet := sc.authJWTSvc.keySet.(*keySetHTTP)
keySet.client = ts.Client()
fn(t, cachingScenarioContext{scenarioContext: sc, reqCount: &reqCount})
}, append([]configureFunc{configure}, cbs...)...)
runner(t)
})
}
func scenario(t *testing.T, desc string, fn scenarioFunc, cbs ...configureFunc) {
t.Helper()
t.Run(desc, scenarioRunner(fn, cbs...))
}
func initAuthService(t *testing.T, cbs ...configureFunc) (*AuthService, error) {
sqlStore := sqlstore.InitTestDB(t)
remoteCacheSvc := &remotecache.RemoteCache{}
cfg := setting.NewCfg()
cfg.JWTAuthEnabled = true
cfg.JWTAuthExpectClaims = "{}"
cfg.RemoteCacheOptions = &setting.RemoteCacheOptions{Name: "database"}
for _, cb := range cbs {
cb(t, cfg)
}
service := &AuthService{}
err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{
{
Name: sqlstore.ServiceName,
Instance: sqlStore,
},
{
Name: remotecache.ServiceName,
Instance: remoteCacheSvc,
},
{
Name: ServiceName,
Instance: service,
},
})
return service, err
}
func scenarioRunner(fn scenarioFunc, cbs ...configureFunc) func(t *testing.T) {
return func(t *testing.T) {
authJWTSvc, err := initAuthService(t, cbs...)
require.NoError(t, err)
fn(t, scenarioContext{
ctx: context.Background(),
cfg: authJWTSvc.Cfg,
authJWTSvc: authJWTSvc,
})
}
}
func configurePKIXPublicKeyFile(t *testing.T, cfg *setting.Cfg) {
t.Helper()
file, err := ioutil.TempFile(os.TempDir(), "public-key-*.pem")
require.NoError(t, err)
t.Cleanup(func() {
if err := os.Remove(file.Name()); err != nil {
panic(err)
}
})
blockBytes, err := x509.MarshalPKIXPublicKey(rsaKeys[0].Public())
require.NoError(t, err)
require.NoError(t, pem.Encode(file, &pem.Block{
Type: "PUBLIC KEY",
Bytes: blockBytes,
}))
require.NoError(t, file.Close())
cfg.JWTAuthKeyFile = file.Name()
}

View File

@@ -0,0 +1,214 @@
package jwt
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
jose "gopkg.in/square/go-jose.v2"
)
var ErrFailedToParsePemFile = errors.New("failed to parse pem-encoded file")
var ErrKeySetIsNotConfigured = errors.New("key set for jwt verification is not configured")
var ErrKeySetConfigurationAmbiguous = errors.New("key set configuration is ambiguous: you should set either key_file, jwk_set_file or jwk_set_url")
var ErrJWTSetURLMustHaveHTTPSScheme = errors.New("jwt_set_url must have https scheme")
type keySet interface {
Key(ctx context.Context, kid string) ([]jose.JSONWebKey, error)
}
type keySetJWKS struct {
jose.JSONWebKeySet
}
type keySetHTTP struct {
url string
log log.Logger
client *http.Client
cache *remotecache.RemoteCache
cacheKey string
cacheExpiration time.Duration
}
func (s *AuthService) checkKeySetConfiguration() error {
var count int
if s.Cfg.JWTAuthKeyFile != "" {
count++
}
if s.Cfg.JWTAuthJWKSetFile != "" {
count++
}
if s.Cfg.JWTAuthJWKSetURL != "" {
count++
}
if count == 0 {
return ErrKeySetIsNotConfigured
}
if count > 1 {
return ErrKeySetConfigurationAmbiguous
}
return nil
}
func (s *AuthService) initKeySet() error {
if err := s.checkKeySetConfiguration(); err != nil {
return err
}
if keyFilePath := s.Cfg.JWTAuthKeyFile; keyFilePath != "" {
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
file, err := os.Open(keyFilePath)
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
s.log.Warn("Failed to close file", "path", keyFilePath, "err", err)
}
}()
data, err := ioutil.ReadAll(file)
if err != nil {
return err
}
block, _ := pem.Decode(data)
if block == nil {
return ErrFailedToParsePemFile
}
var key interface{}
switch block.Type {
case "PUBLIC KEY":
if key, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil {
return err
}
case "PRIVATE KEY":
if key, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil {
return err
}
case "RSA PUBLIC KEY":
if key, err = x509.ParsePKCS1PublicKey(block.Bytes); err != nil {
return err
}
case "RSA PRIVATE KEY":
if key, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
return err
}
case "EC PRIVATE KEY":
if key, err = x509.ParseECPrivateKey(block.Bytes); err != nil {
return err
}
default:
return fmt.Errorf("unknown pem block type %q", block.Type)
}
s.keySet = keySetJWKS{
jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{{Key: key}},
},
}
} else if keyFilePath := s.Cfg.JWTAuthJWKSetFile; keyFilePath != "" {
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `fileName` comes from grafana configuration file
file, err := os.Open(keyFilePath)
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
s.log.Warn("Failed to close file", "path", keyFilePath, "err", err)
}
}()
var jwks jose.JSONWebKeySet
if err := json.NewDecoder(file).Decode(&jwks); err != nil {
return err
}
s.keySet = keySetJWKS{jwks}
} else if urlStr := s.Cfg.JWTAuthJWKSetURL; urlStr != "" {
urlParsed, err := url.Parse(urlStr)
if err != nil {
return err
}
if urlParsed.Scheme != "https" {
return ErrJWTSetURLMustHaveHTTPSScheme
}
s.keySet = &keySetHTTP{
url: urlStr,
log: s.log,
client: &http.Client{},
cacheKey: fmt.Sprintf("auth-jwt:jwk-%s", urlStr),
cacheExpiration: s.Cfg.JWTAuthCacheTTL,
cache: s.RemoteCache,
}
}
return nil
}
func (ks keySetJWKS) Key(ctx context.Context, keyID string) ([]jose.JSONWebKey, error) {
return ks.JSONWebKeySet.Key(keyID), nil
}
func (ks *keySetHTTP) getJWKS(ctx context.Context) (keySetJWKS, error) {
var jwks keySetJWKS
if ks.cacheExpiration > 0 {
if val, err := ks.cache.Get(ks.cacheKey); err == nil {
err := json.Unmarshal(val.([]byte), &jwks)
return jwks, err
}
}
ks.log.Debug("Getting key set from endpoint", "url", ks.url)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ks.url, nil)
if err != nil {
return jwks, err
}
resp, err := ks.client.Do(req)
if err != nil {
return jwks, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
ks.log.Warn("Failed to close response body", "err", err)
}
}()
var jsonBuf bytes.Buffer
if err := json.NewDecoder(io.TeeReader(resp.Body, &jsonBuf)).Decode(&jwks); err != nil {
return jwks, err
}
if ks.cacheExpiration > 0 {
err = ks.cache.Set(ks.cacheKey, jsonBuf.Bytes(), ks.cacheExpiration)
}
return jwks, err
}
func (ks keySetHTTP) Key(ctx context.Context, kid string) ([]jose.JSONWebKey, error) {
jwks, err := ks.getJWKS(ctx)
if err != nil {
return nil, err
}
return jwks.Key(ctx, kid)
}

View File

@@ -0,0 +1,123 @@
package jwt
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
jose "gopkg.in/square/go-jose.v2"
)
var rsaKeys [3]*rsa.PrivateKey
var jwKeys [3]jose.JSONWebKey
var jwksPublic jose.JSONWebKeySet
const rsaKeysPEM = `
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0vKKKQzRHxtnvgvScOvZW2lIBiZ0YBN8ZwAfQdpEQy2w7qAT
WVCr/N/Tj6971gzbwqHL+VIw67SA0o2Ofb/96j2OXsS0mEo/8d2Q3rtOmzAobRBD
fkfVQW3TsbT8Lm+nM9PJkg8+5ZdTYt4ABQRNPFj7jei+udb6mo3aJIFK108K5+gi
gDsgoBS9iZ2CsSymUXd3Sb+WuLx6Cgzx7hjQvsNG4MnQDo1DOFQ+soQqKVRpzH9C
CI574y0fN6TZbi9HehZxgdJgPEiJ9xRuO5pP74fcxEQIcI7lCmBk+t/c3jr4f7Zp
ubLQofvfmF7zSMDveFsh7f80T0SW4/ll4pOJjwIDAQABAoIBAAWiAFp0QylHfA+x
FR96zMUKHKg9YqImIw5FDJCfmW8Jy02z7JBX/R+1glq13uKqWTvrQh0YOsIwgbgd
m450D/2vQxv4uLHQWcDFn9ayvbibIpk28/ZtSJ6EpkB6irlateZGY32I9q7+yXU8
ZFe986oG3kC91En2GZ8C5q5O3Ya+Qyf/uT533cB4u3kMfsBzF38axJgw/z1tW3OJ
XaJHrcYb8yBS0nlrfDQeEvE3JxwtFrtz+oPZ3uqNyUX7Ti0s3HTzG0xs+ctg7Wm9
fRaogbU6wiDMj74ixszdZ7bdgwuETPw85XIcelrlNnCUDCDjmiA83XaxL+0yhOlx
Nb4F2fECgYEA9gK7zaswBsXk9BA73rWbDmO5cJU1dYAkZnWW4kIwjQoccTXAH02l
tvQ9bEyhxp8xf5+H/OLuR8Pc0sP7FCu/UojaQ+4js3DKC5tccMWAmxDrqySilXqU
P3uENNXVp4EyQ2BJQ/t3vuuXxXaAg9FnilK/MZDxdoG9lLQpzHq4BVMCgYEA24NT
WqfTMAnOE4bYKUHPCpaEbwNNEMZnYZDUruHXSZaOqb4QAqNcVsiVWauAn1KEyxV0
ybTt6lULRu1JyLM31kAAKiJqxCYV7gXdBoJymDx1FU8SmE7YR2cKVGJM7YhuKAZI
VGg51KgeRldWlDF2y9zoDRmqwwNLmb6RZpxJh1UCgYAGGodCUQ79/Ab0LzrtCaBx
OPQu4OTUp3s/t4co0e+WcDvIa0b6/9gus9yaRUR2QxjdS735/j9fNHLUH9yo4XT7
vT19Ffl4yEGbDB29BolsT30pX91QzBvFf3EGRo/oegIfPdJTh3evGvVHBuulWZqy
Cd+IgUocYJetitLGqfzK1QKBgQCtm9e4wzKLs7WATA1508pjnVdwVTQGKGRrDZio
F4WldaWvKdqPu5O0Lz+vg6xeVW0hEP8k6CuiQVCB7/mC+fsXP9bhfAbkyxpc/dYo
59KqBGa1S6xxOSpkjmHlCzm8Q0Kb9RwPZb8XKT+IclrPKa/C3BvLAJnFUj3ggo+M
j963YQKBgQCDByqxFdPTzeHpZnLHHmmYAN9HbNoxLDX4JCB2iT20lQu1Agp2DgBA
CKlDUOMt6UUIGYKD+Hn8RDUQN3Um8nDaa5RFsy7F9Og57M/VLdvRb0mBvTdgiKSB
wcPDty9QlZ5dfLsagYG5rcFGCDt3FZvpPyaeYcxK9/QC5VvAHj6gbA==
-----END RSA PRIVATE KEY-----
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApuLHCQ7uIeWX+fszaToHnBLufpa32EsaQ4pLCKdI/Zflc4VR
eyArQvMm1jUXggCvyAzLCgOLYRcXIOPAbzanLd1Fh+BmqBs0jacLfxD+vN2OEqpw
e7Li3YTN3d032wO9bL2XmUEgoZkUcmkJGoTT34lMDyTgIexWv/4WXwQo6ZdukGqG
OWlFZJwOv/gIZkCk3uUIgvtkp6UAxzIl9EsH5MdEwaHH3hlvao0IFpA/Gfg3VKY6
SnqtIH11Ck2XQD2c0kE7yPkRh71gdTO1tXn1Ch/sWhqprAjqXqNOZIFir4dMKfc+
I25hDxFtp/eUbKExe+/tsuS1CFEDLJJwdVmDMQIDAQABAoIBABBdrv0xbKMAXA4U
127bVQG2TZM2fqMEgnfbKQ6ZMSxFaCgTC/GSLwvqwoVBQaPrI1HwBz1dKZ8E36zH
CQkfB/gUegwgMpEL0fSOTC9S4FhvNc6Yzl0jJuJocrPuTNr6m+n9Ec/itiuC0qGB
sXXbTtfeJApcKGrLPZqodVMuGkEGAyqbiPdp6eYC3C+lkitDcOHCG6Z4WzA6HFr/
Y8TkozUcbgqX18zVUGI7147mYNdQW2Ap20hPqfO1A9MtbqEx05NxQiTsbf8U+QGo
NJ24es3c3fFVZambp/RXfLQuPBciTKmQoD6fWWOybB1a5/Owbb7+aiPAySrSRPHE
Um3wnc0CgYEAxefN703MWHv1fkQGa8fNNw/DoKh5w9nLrNdrfnWGWDvsy9giW1Xq
QYL+Dq+17vQ+TdWOeUWeCG679Oj/bvRiZy15ikR1/KmM3v4KkG3/P+ugLZRdd32X
Ldpg1f3MNtLVXgAbTtd6oy7FVcPThP54s2A5Ab/2yLCq+mFDn5ahsecCgYEA19/p
C0WEvChPzXJanb2npJHy8gHwkuEn0D9zNzKX6ToOKwoqIMjTt2wmbHJd42sbCOc0
S7UKcIof11q7lV7/TnLB+V4C8f6dKe34lF+dGfXDljcqu+uNwXS1JQvQST5OwcGb
foXDtDH10yZroEaCnXBuQ0szMbSZVlh2M67CLycCgYA/tSxU8b1rapQPjoRmo84L
AJcgG7v+8RigzkP7VIfn1XqX8D63GkQrzKhOQAAYKSX5Vlrj7SY/Xq5A29SGekNH
JZtviDRXHpmLm0n5Tn+Rqx9ILO+drJ9DEn6DxIy9xUcMWIpx6em/qCm8PyrTMDvY
uov/ZTVjS4Puz+q97/ajVwKBgCKIm0tGT7mZ6UpAZOafFFZrUqYMUWPtyOSzgcbu
vQZ+Vw3jjmG4PsY08uCeWw6qb6S499C0oXrnXbihtyhqDgWKriUqOOZliNbQTtfN
g+BHRIafRKLTR9YOyXunrCZLZWXxhuJym6AT7fNdThJRtUtiVQFG4mWtMmpxtFcI
OeA5AoGBALI2NDwPF2MCPIQRpkL9aH3RwnBXNFqKseww5IDWbbjL8VaVPPZeDrDg
7gpfPCor+Acd+Rj2qSWgQG2dkG+tv5+zT954fGcEQsV9L/PXl1KrQmgEO3bHFeCn
FNm+zIsFGY58CHyj8sCA9Sq23JlSx5y2E1SF2Skyp2+0n7tdLaOd
-----END RSA PRIVATE KEY-----
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAw45ZGszAxk7hg4sosECMtQz/z6nt2OMkgi0uZCm+2h6js/1l
wD4ZBjeS9eR6fE3o2pF/jK9Xd2MRHREeaU6ha1d1HmCyTjWnMNgZP6ZZd9xmOznr
bZx4iyXw8EQ3FC+4lg46XFUeYJLwRZKlt2Ks+s0kWLpE6/KP/TK2LOm710k/9W8/
jlmUkOiw4uYEX8HPSgTeWKZbaVxppTG7CzDD722Q9ZCXGwTJ8uWN5VFdvRXEeHAt
XmsHJmHEjR+R1fJvERgEdRqnEaIsMkRmzRCYr6MKj2Xb+Ro6hU3/7grVzHEKX9jT
DAIX1rCkL0yF5Ka0cLbylUM1ZwpXyDpblQeOiQIDAQABAoIBADkhOvLTYnYM0WEm
pGppUTILbCh00mGMajwFiwoEEBeU3+pTWwiAm7rvPWXMq+PotuAzpXmqN/lO3c8K
E0JckFfVoweO5Eho8EEawLWRmY2ku9ENqLPLBIRSP0NSCm1BS8G6wl37F/bKtpr8
rqEWmMZka/vn3v63TE2CJSqV1iScZLdAJQT8kQNOhDYKXK+yXkcKSXgRSc0KLiYe
GL03cksYp8cBBQEtpGFw4RgK2f9sTuM5L26eOhhDEh46ByKb7cFhTto4VX/Gs61d
92Etkz9TNAEMXNT8DnPdR58/n/9DKf+tGRJD1YktcNOKMzGTxY64QEjcpxFFvRe1
HwyyRQECgYEA0u4k1J0253lhrKAN0A3vmhrUR1jGMMCrtB6WbGZUNbdxiDqGYp+D
x7FJQm2hAth1hSw9js+wkcc18Nn+zQQ+cfNNP69+3W/eLi1JktlZUdDRoN+uioZe
A21FyMY1HbfTcRcnfTKv5kbCYCJBi0z1D5x+YWCdmnFFzNwfG7cu1tkCgYEA7Vc8
xpkUeejefustUGXWMVCOXeafBnIwyWrzyN0SC7zhA1ws/v78MBPPxAjKghD1vKSh
1H+frio7wpFMLe7WZpGfqFoCllARBCR1smcDnoSqRY4EM+FhmDHXcfQ00sHOpztj
KKnlRs75mrVQ2HkPwpYMdNgpM/piIK8oqe1khzECgYAqND4oUICg1hemC6xX2cH8
Sqv4zplxPcvdUVV1wQ/OY7MSt+sVpqceeKmY4giaYic5iz2R6pqAwKRZWbTy3ouE
D1OAj6PJuM1y3drfyB9oEGkxUDBDRVlgRCf3YTlVheeHtENReKfbYoMX6yLENZS/
F+ftogBG261ErTKIQCHeGQKBgQCMMdO8m//0YxHKdrC1pPH4/1SZMvkMnbcjwwFt
zOgz9sYTbgdGOOhOneVELs0wN0Rwwe61zw1Lm7bhH2KYX1RWEf71OvX8RB9JCyBa
2W7R3BuYKmNhIei8NfTFYzMwqzqenf3crz63rNrT//ZZaGlez7Nb8bOk+GmuVMj4
VznigQKBgGHaNPvpmcMFMFeocDM9pGQ/KtzgVUU9mfd4yqhTyQ/pZ+XDPdz0DBQK
lzlCHD844HY1BjQurECe9QgqUB/slaMC3hl7l6bPNHQk7/plNsER7E7hzN6f6PZM
kXxSnDcVQGY0cWZ0FROyYbBp3nBVA5VT5HYYGfazhsisIHP+3zoG
-----END RSA PRIVATE KEY-----
`
func init() {
data := []byte(rsaKeysPEM)
for i := 0; i < len(rsaKeys); i++ {
var block *pem.Block
block, data = pem.Decode(data)
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
panic(err)
}
keyID := fmt.Sprintf("key-id-%02d", i)
rsaKeys[i] = key
jwKeys[i].KeyID = keyID
jwKeys[i].Key = key
// Last key is reserved to test as an unknown key.
if i < len(rsaKeys)-1 {
jwksPublic.Keys = append(jwksPublic.Keys, jose.JSONWebKey{
KeyID: keyID,
Key: key.Public(),
})
}
}
}

View File

@@ -0,0 +1,43 @@
package jwt
import (
"testing"
"github.com/stretchr/testify/require"
jose "gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)
type noneSigner struct{}
func sign(t *testing.T, key interface{}, claims interface{}) string {
t.Helper()
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.PS512, Key: key}, (&jose.SignerOptions{}).WithType("JWT"))
require.NoError(t, err)
token, err := jwt.Signed(sig).Claims(claims).CompactSerialize()
require.NoError(t, err)
return token
}
func (s noneSigner) Public() *jose.JSONWebKey {
return nil
}
func (s noneSigner) Algs() []jose.SignatureAlgorithm {
return []jose.SignatureAlgorithm{"none"}
}
func (s noneSigner) SignPayload(payload []byte, alg jose.SignatureAlgorithm) ([]byte, error) {
return nil, nil
}
func signNone(t *testing.T, claims interface{}) string {
t.Helper()
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: "none", Key: noneSigner{}}, (&jose.SignerOptions{}).WithType("JWT"))
require.NoError(t, err)
token, err := jwt.Signed(sig).Claims(claims).CompactSerialize()
require.NoError(t, err)
return token
}

View File

@@ -0,0 +1,137 @@
package jwt
import (
"encoding/json"
"fmt"
"reflect"
"time"
"github.com/grafana/grafana/pkg/models"
"gopkg.in/square/go-jose.v2/jwt"
)
func (s *AuthService) initClaimExpectations() error {
if err := json.Unmarshal([]byte(s.Cfg.JWTAuthExpectClaims), &s.expect); err != nil {
return err
}
for key, value := range s.expect {
switch key {
case "iss":
if stringValue, ok := value.(string); ok {
s.expectRegistered.Issuer = stringValue
} else {
return fmt.Errorf("%q expectation has invalid type %T, string expected", key, value)
}
delete(s.expect, key)
case "sub":
if stringValue, ok := value.(string); ok {
s.expectRegistered.Subject = stringValue
} else {
return fmt.Errorf("%q expectation has invalid type %T, string expected", key, value)
}
delete(s.expect, key)
case "aud":
switch value := value.(type) {
case []interface{}:
for _, val := range value {
if val, ok := val.(string); ok {
s.expectRegistered.Audience = append(s.expectRegistered.Audience, val)
} else {
return fmt.Errorf("%q expectation contains value with invalid type %T, string expected", key, val)
}
}
case string:
s.expectRegistered.Audience = []string{value}
default:
return fmt.Errorf("%q expectation has invalid type %T, array or string expected", key, value)
}
delete(s.expect, key)
}
}
return nil
}
func (s *AuthService) validateClaims(claims models.JWTClaims) error {
var registeredClaims jwt.Claims
for key, value := range claims {
switch key {
case "iss":
if stringValue, ok := value.(string); ok {
registeredClaims.Issuer = stringValue
} else {
return fmt.Errorf("%q claim has invalid type %T, string expected", key, value)
}
case "sub":
if stringValue, ok := value.(string); ok {
registeredClaims.Subject = stringValue
} else {
return fmt.Errorf("%q claim has invalid type %T, string expected", key, value)
}
case "aud":
switch value := value.(type) {
case []interface{}:
for _, val := range value {
if val, ok := val.(string); ok {
registeredClaims.Audience = append(registeredClaims.Audience, val)
} else {
return fmt.Errorf("%q claim contains value with invalid type %T, string expected", key, val)
}
}
case string:
registeredClaims.Audience = []string{value}
default:
return fmt.Errorf("%q claim has invalid type %T, array or string expected", key, value)
}
case "exp":
if value == nil {
continue
}
if floatValue, ok := value.(float64); ok {
out := jwt.NumericDate(floatValue)
registeredClaims.Expiry = &out
} else {
return fmt.Errorf("%q claim has invalid type %T, number expected", key, value)
}
case "nbf":
if value == nil {
continue
}
if floatValue, ok := value.(float64); ok {
out := jwt.NumericDate(floatValue)
registeredClaims.NotBefore = &out
} else {
return fmt.Errorf("%q claim has invalid type %T, number expected", key, value)
}
case "iat":
if value == nil {
continue
}
if floatValue, ok := value.(float64); ok {
out := jwt.NumericDate(floatValue)
registeredClaims.IssuedAt = &out
} else {
return fmt.Errorf("%q claim has invalid type %T, number expected", key, value)
}
}
}
expectRegistered := s.expectRegistered
expectRegistered.Time = time.Now()
if err := registeredClaims.Validate(expectRegistered); err != nil {
return err
}
for key, expected := range s.expect {
value, ok := claims[key]
if !ok {
return fmt.Errorf("%q claim is missing", key)
}
if !reflect.DeepEqual(expected, value) {
return fmt.Errorf("%q claim mismatch", key)
}
}
return nil
}

View File

@@ -0,0 +1,64 @@
package contexthandler
import (
"errors"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/models"
)
const InvalidJWT = "Invalid JWT"
func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool {
if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" {
return false
}
jwtToken := ctx.Req.Header.Get(h.Cfg.JWTAuthHeaderName)
if jwtToken == "" {
return false
}
claims, err := h.JWTAuthService.Verify(ctx.Req.Context(), jwtToken)
if err != nil {
ctx.Logger.Debug("Failed to verify JWT", "error", err)
ctx.JsonApiErr(401, InvalidJWT, err)
return true
}
query := models.GetSignedInUserQuery{OrgId: orgId}
if key := h.Cfg.JWTAuthUsernameClaim; key != "" {
query.Login, _ = claims[key].(string)
}
if key := h.Cfg.JWTAuthEmailClaim; key != "" {
query.Email, _ = claims[key].(string)
}
if query.Login == "" && query.Email == "" {
ctx.Logger.Debug("Failed to get an authentication claim from JWT")
ctx.JsonApiErr(401, InvalidJWT, err)
return true
}
if err := bus.Dispatch(&query); err != nil {
if errors.Is(err, models.ErrUserNotFound) {
ctx.Logger.Debug(
"Failed to find user using JWT claims",
"email_claim", query.Email,
"username_claim", query.Login,
)
err = login.ErrInvalidCredentials
} else {
ctx.Logger.Error("Failed to get signed in user", "error", err)
}
ctx.JsonApiErr(401, InvalidJWT, err)
return true
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
return true
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -107,6 +108,7 @@ func getContextHandler(t *testing.T) *ContextHandler {
cfg.AuthProxyHeaderProperty = "username"
userAuthTokenSvc := auth.NewFakeUserAuthTokenService()
renderSvc := &fakeRenderService{}
authJWTSvc := models.NewFakeJWTService()
svc := &ContextHandler{}
err := registry.BuildServiceGraph([]interface{}{cfg}, []*registry.Descriptor{
@@ -126,6 +128,10 @@ func getContextHandler(t *testing.T) *ContextHandler {
Name: rendering.ServiceName,
Instance: renderSvc,
},
{
Name: jwt.ServiceName,
Instance: authJWTSvc,
},
{
Name: ServiceName,
Instance: svc,

View File

@@ -44,6 +44,7 @@ func init() {
type ContextHandler struct {
Cfg *setting.Cfg `inject:""`
AuthTokenService models.UserTokenService `inject:""`
JWTAuthService models.JWTService `inject:""`
RemoteCache *remotecache.RemoteCache `inject:""`
RenderService rendering.Service `inject:""`
SQLStore *sqlstore.SQLStore `inject:""`
@@ -92,6 +93,7 @@ func (h *ContextHandler) Middleware(c *macaron.Context) {
case h.initContextWithBasicAuth(ctx, orgID):
case h.initContextWithAuthProxy(ctx, orgID):
case h.initContextWithToken(ctx, orgID):
case h.initContextWithJWT(ctx, orgID):
case h.initContextWithAnonymousUser(ctx):
}