diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 086f70b7ece..89344ca3d26 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,6 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.21.10 require ( + github.com/grafana/authlib v0.0.0-20240730122259-a0d13672efb1 github.com/stretchr/testify v1.9.0 k8s.io/apimachinery v0.29.3 k8s.io/apiserver v0.29.2 @@ -12,6 +13,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.4 // indirect @@ -25,10 +27,15 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/crypto v0.24.0 // indirect golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index 93d7d4e25d0..9a61a32f216 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -1,5 +1,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= @@ -9,6 +10,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/grafana/authlib v0.0.0-20240730122259-a0d13672efb1 h1:EiaupmOnt6XF/LPxvagjTofWmByzYaf5VyMIF+w/71M= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -16,12 +18,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= diff --git a/pkg/apimachinery/identity/requester.go b/pkg/apimachinery/identity/requester.go index da9609b69a0..9da9d99286e 100644 --- a/pkg/apimachinery/identity/requester.go +++ b/pkg/apimachinery/identity/requester.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + authnlib "github.com/grafana/authlib/authn" "k8s.io/apiserver/pkg/authentication/user" ) @@ -77,6 +78,8 @@ type Requester interface { // GetIDToken returns a signed token representing the identity that can be forwarded to plugins and external services. // Will only be set when featuremgmt.FlagIdForwarding is enabled. GetIDToken() string + // GetIDClaims returns the claims of the ID token. + GetIDClaims() *authnlib.Claims[authnlib.IDTokenClaims] } // IntIdentifier converts a string identifier to an int64. diff --git a/pkg/apimachinery/identity/static.go b/pkg/apimachinery/identity/static.go index af4f5fa0565..2fe79aaee8a 100644 --- a/pkg/apimachinery/identity/static.go +++ b/pkg/apimachinery/identity/static.go @@ -1,6 +1,10 @@ package identity -import "fmt" +import ( + "fmt" + + authnlib "github.com/grafana/authlib/authn" +) var _ Requester = &StaticRequester{} @@ -25,9 +29,10 @@ type StaticRequester struct { AllowedKubernetesNamespace string IsGrafanaAdmin bool // Permissions grouped by orgID and actions - Permissions map[int64]map[string][]string - IDToken string - CacheKey string + Permissions map[int64]map[string][]string + IDToken string + IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] + CacheKey string } // GetRawIdentifier implements Requester. @@ -208,3 +213,7 @@ func (u *StaticRequester) GetDisplayName() string { func (u *StaticRequester) GetIDToken() string { return u.IDToken } + +func (u *StaticRequester) GetIDClaims() *authnlib.Claims[authnlib.IDTokenClaims] { + return u.IDTokenClaims +} diff --git a/pkg/services/auth/id.go b/pkg/services/auth/id.go index 63fa61c05c9..18096eee393 100644 --- a/pkg/services/auth/id.go +++ b/pkg/services/auth/id.go @@ -10,7 +10,7 @@ import ( type IDService interface { // SignIdentity signs a id token for provided identity that can be forwarded to plugins and external services - SignIdentity(ctx context.Context, identity identity.Requester) (string, error) + SignIdentity(ctx context.Context, id identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) // RemoveIDToken removes any locally stored id tokens for key RemoveIDToken(ctx context.Context, identity identity.Requester) error diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go index d5694c35cfb..f48bf1c700b 100644 --- a/pkg/services/auth/idimpl/service.go +++ b/pkg/services/auth/idimpl/service.go @@ -58,21 +58,30 @@ type Service struct { nsMapper request.NamespaceMapper } -func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (string, error) { +func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) { defer func(t time.Time) { s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds()) }(time.Now()) cacheKey := prefixCacheKey(id.GetCacheKey()) - result, err, _ := s.si.Do(cacheKey, func() (interface{}, error) { + type resultType struct { + token string + idClaims *auth.IDClaims + } + result, err, _ := s.si.Do(cacheKey, func() (any, error) { namespace, identifier := id.GetTypedID() cachedToken, err := s.cache.Get(ctx, cacheKey) if err == nil { s.metrics.tokenSigningFromCacheCounter.Inc() s.logger.FromContext(ctx).Debug("Cached token found", "namespace", namespace, "id", identifier) - return string(cachedToken), nil + + tokenClaims, err := s.extractTokenClaims(string(cachedToken)) + if err != nil { + return resultType{}, err + } + return resultType{token: string(cachedToken), idClaims: tokenClaims}, nil } s.metrics.tokenSigningCounter.Inc() @@ -104,21 +113,12 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri token, err := s.signer.SignIDToken(ctx, claims) if err != nil { s.metrics.failedTokenSigningCounter.Inc() - return "", err + return resultType{}, nil } - parsed, err := jwt.ParseSigned(token) + extracted, err := s.extractTokenClaims(token) if err != nil { - s.metrics.failedTokenSigningCounter.Inc() - return "", err - } - - extracted := auth.IDClaims{} - // We don't need to verify the signature here, we are only interested in checking - // when the token expires. - if err := parsed.UnsafeClaimsWithoutVerification(&extracted); err != nil { - s.metrics.failedTokenSigningCounter.Inc() - return "", err + return resultType{}, err } expires := time.Until(extracted.Expiry.Time()) @@ -126,14 +126,14 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri s.logger.FromContext(ctx).Error("Failed to add id token to cache", "error", err) } - return token, nil + return resultType{token: token, idClaims: claims}, nil }) if err != nil { - return "", err + return "", nil, err } - return result.(string), nil + return result.(resultType).token, result.(resultType).idClaims, nil } func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error { @@ -142,7 +142,7 @@ func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) erro func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { // FIXME(kalleep): we should probably lazy load this - token, err := s.SignIdentity(ctx, identity) + token, claims, err := s.SignIdentity(ctx, identity) if err != nil { if shouldLogErr(err) { namespace, id := identity.GetTypedID() @@ -153,9 +153,28 @@ func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.R } identity.IDToken = token + identity.IDTokenClaims = claims return nil } +func (s *Service) extractTokenClaims(token string) (*authnlib.Claims[authnlib.IDTokenClaims], error) { + parsed, err := jwt.ParseSigned(token) + if err != nil { + s.metrics.failedTokenSigningCounter.Inc() + return nil, err + } + + extracted := authnlib.Claims[authnlib.IDTokenClaims]{} + // We don't need to verify the signature here, we are only interested in checking + // when the token expires. + if err := parsed.UnsafeClaimsWithoutVerification(&extracted); err != nil { + s.metrics.failedTokenSigningCounter.Inc() + return nil, err + } + + return &extracted, nil +} + func getAudience(orgID int64) jwt.Audience { return jwt.Audience{fmt.Sprintf("org:%d", orgID)} } diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go index 6b619b5075f..3cb8b36a7b9 100644 --- a/pkg/services/auth/idimpl/service_test.go +++ b/pkg/services/auth/idimpl/service_test.go @@ -70,7 +70,7 @@ func TestService_SignIdentity(t *testing.T) { featuremgmt.WithFeatures(featuremgmt.FlagIdForwarding), &authntest.FakeService{}, nil, ) - token, err := s.SignIdentity(context.Background(), &authn.Identity{ID: identity.MustParseTypedID("user:1")}) + token, _, err := s.SignIdentity(context.Background(), &authn.Identity{ID: identity.MustParseTypedID("user:1")}) require.NoError(t, err) require.NotEmpty(t, token) }) @@ -81,7 +81,7 @@ func TestService_SignIdentity(t *testing.T) { featuremgmt.WithFeatures(featuremgmt.FlagIdForwarding), &authntest.FakeService{}, nil, ) - token, err := s.SignIdentity(context.Background(), &authn.Identity{ + token, _, err := s.SignIdentity(context.Background(), &authn.Identity{ ID: identity.MustParseTypedID("user:1"), AuthenticatedBy: login.AzureADAuthModule, Login: "U1", @@ -97,4 +97,22 @@ func TestService_SignIdentity(t *testing.T) { assert.Equal(t, "U1", claims.Rest.Username) assert.Equal(t, "user:edpu3nnt61se8e", claims.Rest.UID) }) + + t.Run("should sign identity with authenticated by if user is externally authenticated", func(t *testing.T) { + s := ProvideService( + setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(), + featuremgmt.WithFeatures(featuremgmt.FlagIdForwarding), + &authntest.FakeService{}, nil, + ) + _, gotClaims, err := s.SignIdentity(context.Background(), &authn.Identity{ + ID: identity.MustParseTypedID("user:1"), + AuthenticatedBy: login.AzureADAuthModule, + Login: "U1", + UID: identity.NewTypedIDString(identity.TypeUser, "edpu3nnt61se8e")}) + require.NoError(t, err) + + assert.Equal(t, login.AzureADAuthModule, gotClaims.Rest.AuthenticatedBy) + assert.Equal(t, "U1", gotClaims.Rest.Username) + assert.Equal(t, "user:edpu3nnt61se8e", gotClaims.Rest.UID) + }) } diff --git a/pkg/services/auth/idtest/mock.go b/pkg/services/auth/idtest/mock.go index 2613d6a7690..cd506836d13 100644 --- a/pkg/services/auth/idtest/mock.go +++ b/pkg/services/auth/idtest/mock.go @@ -3,6 +3,8 @@ package idtest import ( "context" + authnlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/auth" ) @@ -10,15 +12,15 @@ import ( var _ auth.IDService = (*MockService)(nil) type MockService struct { - SignIdentityFn func(ctx context.Context, identity identity.Requester) (string, error) + SignIdentityFn func(ctx context.Context, identity identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) RemoveIDTokenFn func(ctx context.Context, identity identity.Requester) error } -func (m *MockService) SignIdentity(ctx context.Context, identity identity.Requester) (string, error) { +func (m *MockService) SignIdentity(ctx context.Context, identity identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) { if m.SignIdentityFn != nil { return m.SignIdentityFn(ctx, identity) } - return "", nil + return "", nil, nil } func (m *MockService) RemoveIDToken(ctx context.Context, identity identity.Requester) error { diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go index 51bd72e5156..b41c65c3bba 100644 --- a/pkg/services/authn/identity.go +++ b/pkg/services/authn/identity.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/grafana/authlib/authn" "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -69,7 +70,8 @@ type Identity struct { Permissions map[int64]map[string][]string // IDToken is a signed token representing the identity that can be forwarded to plugins and external services. // Will only be set when featuremgmt.FlagIdForwarding is enabled. - IDToken string + IDToken string + IDTokenClaims *authn.Claims[authn.IDTokenClaims] } // GetRawIdentifier implements Requester. @@ -156,6 +158,10 @@ func (i *Identity) GetIDToken() string { return i.IDToken } +func (i *Identity) GetIDClaims() *authn.Claims[authn.IDTokenClaims] { + return i.IDTokenClaims +} + func (i *Identity) GetIsGrafanaAdmin() bool { return i.IsGrafanaAdmin != nil && *i.IsGrafanaAdmin } diff --git a/pkg/services/user/identity.go b/pkg/services/user/identity.go index b5906276ea5..c05293ae4b5 100644 --- a/pkg/services/user/identity.go +++ b/pkg/services/user/identity.go @@ -5,6 +5,8 @@ import ( "strconv" "time" + authnlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) @@ -40,9 +42,11 @@ type SignedInUser struct { Teams []int64 // Permissions grouped by orgID and actions Permissions map[int64]map[string][]string `json:"-"` + // IDToken is a signed token representing the identity that can be forwarded to plugins and external services. // Will only be set when featuremgmt.FlagIdForwarding is enabled. - IDToken string `json:"-" xorm:"-"` + IDToken string `json:"-" xorm:"-"` + IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] `json:"-" xorm:"-"` // When other settings are not deterministic, this value is used FallbackType identity.IdentityType @@ -309,3 +313,7 @@ func (u *SignedInUser) GetDisplayName() string { func (u *SignedInUser) GetIDToken() string { return u.IDToken } + +func (u *SignedInUser) GetIDClaims() *authnlib.Claims[authnlib.IDTokenClaims] { + return u.IDTokenClaims +}