AuthN: Embed an OAuth2 server for external service authentication (#68086)

* Moving POC files from #64283 to a new branch

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Adding missing permission definition

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Force the service instantiation while client isn't merged

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Merge conf with main

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Leave go-sqlite3 version unchanged

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* tidy

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* User SearchUserPermissions instead of SearchUsersPermissions

* Replace DummyKeyService with signingkeys.Service

* Use user🆔<id> as subject

* Fix introspection endpoint issue

* Add X-Grafana-Org-Id to get_resources.bash script

* Regenerate toggles_gen.go
* Fix basic.go

* Add GetExternalService tests

* Add GetPublicKeyScopes tests

* Add GetScopesOnUser tests

* Add GetScopes tests

* Add ParsePublicKeyPem tests

* Add database test for GetByName

* re-add comments

* client tests added

* Add GetExternalServicePublicKey tests

* Add other test case to GetExternalServicePublicKey

* client_credentials grant test

* Add test to jwtbearer grant

* Test Comments

* Add handleKeyOptions tests

* Add RSA key generation test

* Add ECDSA by default to EmbeddedSigningKeysService

* Clean up org id scope and audiences

* Add audiences to the DB

* Fix check on Audience

* Fix double import

* Add AC Store mock and align oauthserver tests

* Fix test after rebase

* Adding missing store function to mock

* Fix double import

* Add CODEOWNER

* Fix some linting errors

* errors don't need type assertion

* Typo codeowners

* use mockery for oauthserver store

* Add feature toggle check

* Fix db tests to handle the feature flag

* Adding call to DeleteExternalServiceRole

* Fix flaky test

* Re-organize routes comments and plan futur work

* Add client_id check to Extended JWT client

* Clean up

* Fix

* Remove background service registry instantiation of the OAuth server

* Comment cleanup

* Remove unused client function

* Update go.mod to use the latest ory/fosite commit

* Remove oauth2_server related configs from defaults.ini

* Add audiences to DTO

* Fix flaky test

* Remove registration endpoint and demo scripts. Document code

* Rename packages

* Remove the OAuthService vs OAuthServer confusion

* fix incorrect import ext_jwt_test

* Comments and order

* Comment basic auth

* Remove unecessary todo

* Clean api

* Moving ParsePublicKeyPem to utils

* re ordering functions in service.go

* Fix comment

* comment on the redirect uri

* Add RBAC actions, not only scopes

* Fix tests

* re-import featuremgmt in migrations

* Fix wire

* Fix scopes in test

* Fix flaky test

* Remove todo, the intersection should always return the minimal set

* Remove unecessary check from intersection code

* Allow env overrides on settings

* remove the term app name

* Remove app keyword for client instead and use Name instead of ExternalServiceName

* LogID remove ExternalService ref

* Use Name instead of ExternalServiceName

* Imports order

* Inline

* Using ExternalService and ExternalServiceDTO

* Remove xorm tags

* comment

* Rename client files

* client -> external service

* comments

* Move test to correct package

* slimmer test

* cachedUser -> cachedExternalService

* Fix aggregate store test

* PluginAuthSession -> AuthSession

* Revert the nil cehcks

* Remove unecessary extra

* Removing custom session

* fix typo in test

* Use constants for tests

* Simplify HandleToken tests

* Refactor the HandleTokenRequest test

* test message

* Review test

* Prevent flacky test on client as well

* go imports

* Revert changes from 526e48ad45

* AuthN: Change the External Service registration form (#68649)

* AuthN: change the External Service registration form

* Gen default permissions

* Change demo script registration form

* Remove unecessary comment

* Nit.

* Reduce cyclomatic complexity

* Remove demo_scripts

* Handle case with no service account

* Comments

* Group key gen

* Nit.

* Check the SaveExternalService test

* Rename cachedUser to cachedClient in test

* One more test case to database test

* Comments

* Remove last org scope

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Update pkg/services/oauthserver/utils/utils_test.go

* Update pkg/services/sqlstore/migrations/oauthserver/migrations.go

Remove comment

* Update pkg/setting/setting.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

---------

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE 2023-05-25 15:38:30 +02:00 committed by GitHub
parent 73681a251e
commit edf1775d49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 5190 additions and 113 deletions

1
.github/CODEOWNERS vendored
View File

@ -545,6 +545,7 @@ lerna.json @grafana/frontend-ops
/pkg/services/ldap/ @grafana/grafana-authnz-team
/pkg/services/login/ @grafana/grafana-authnz-team
/pkg/services/loginattempt/ @grafana/grafana-authnz-team
/pkg/services/oauthserver/ @grafana/grafana-authnz-team
/pkg/services/oauthtoken/ @grafana/grafana-authnz-team
/pkg/services/serviceaccounts/ @grafana/grafana-authnz-team

27
go.mod
View File

@ -266,10 +266,12 @@ require (
github.com/grafana/go-mssqldb v0.9.1
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482
github.com/grafana/thema v0.0.0-20230523123227-db9596a7096e
github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f
github.com/redis/go-redis/v9 v9.0.2
github.com/weaveworks/common v0.0.0-20230208133027-16871410fca4
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f
go.opentelemetry.io/contrib/samplers/jaegerremote v0.9.0
gopkg.in/square/go-jose.v2 v2.5.2-0.20210529014059-a5c7eec3c614
k8s.io/utils v0.0.0-20230308161112-d77c459e9343
)
@ -290,13 +292,19 @@ require (
github.com/cockroachdb/redact v1.1.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/cristalhq/jwt/v4 v4.0.2 // indirect
github.com/dave/jennifer v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/drone-runners/drone-runner-docker v1.8.2 // indirect
github.com/drone/drone-go v1.7.1 // indirect
github.com/drone/envsubst v1.0.3 // indirect
github.com/drone/runner-go v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/ecordell/optgen v0.0.6 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/getsentry/sentry-go v0.12.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
@ -304,20 +312,32 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd // indirect
github.com/grafana/sqlds/v2 v2.3.10 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/memberlist v0.5.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/invopop/yaml v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.3 // indirect
github.com/mattn/goveralls v0.0.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect
github.com/ory/go-acc v0.2.6 // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/viper v1.7.5 // indirect
github.com/ory/x v0.0.214 // indirect
github.com/pborman/uuid v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/perimeterx/marshmallow v1.1.4 // indirect
github.com/rivo/uniseg v0.3.4 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
@ -325,7 +345,12 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect
@ -423,3 +448,5 @@ replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20220421
// Use 1.10.6 of pq to avoid a change in 1.10.7 that has certificate validation issues. https://github.com/grafana/grafana/issues/65816
replace github.com/lib/pq => github.com/lib/pq v1.10.6
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible

594
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,8 @@ import (
ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/oasimpl"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
@ -354,6 +356,8 @@ var wireBasicSet = wire.NewSet(
wire.Bind(new(tag.Service), new(*tagimpl.Service)),
authnimpl.ProvideService,
supportbundlesimpl.ProvideService,
oasimpl.ProvideService,
wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)),
loggermw.Provide,
modules.WireSet,
signingkeysimpl.ProvideEmbeddedSigningKeysService,

View File

@ -196,6 +196,7 @@ func GroupScopesByAction(permissions []Permission) map[string][]string {
return m
}
// Reduce will reduce a list of permissions to its minimal form, grouping scopes by action
func Reduce(ps []Permission) map[string][]string {
reduced := make(map[string][]string)
scopesByAction := make(map[string]map[string]bool)
@ -260,6 +261,110 @@ func Reduce(ps []Permission) map[string][]string {
return reduced
}
// intersectScopes computes the minimal list of scopes common to two slices.
func intersectScopes(s1, s2 []string) []string {
if len(s1) == 0 || len(s2) == 0 {
return []string{}
}
// helpers
splitScopes := func(s []string) (map[string]bool, map[string]bool) {
scopes := make(map[string]bool)
wildcards := make(map[string]bool)
for _, s := range s {
if isWildcard(s) {
wildcards[s] = true
} else {
scopes[s] = true
}
}
return scopes, wildcards
}
includes := func(wildcardsSet map[string]bool, scope string) bool {
for wildcard := range wildcardsSet {
if wildcard == "*" || strings.HasPrefix(scope, wildcard[:len(wildcard)-1]) {
return true
}
}
return false
}
res := make([]string, 0)
// split input into scopes and wildcards
s1Scopes, s1Wildcards := splitScopes(s1)
s2Scopes, s2Wildcards := splitScopes(s2)
// intersect wildcards
wildcards := make(map[string]bool)
for s := range s1Wildcards {
// if s1 wildcard is included in s2 wildcards
// then it is included in the intersection
if includes(s2Wildcards, s) {
wildcards[s] = true
continue
}
}
for s := range s2Wildcards {
// if s2 wildcard is included in s1 wildcards
// then it is included in the intersection
if includes(s1Wildcards, s) {
wildcards[s] = true
}
}
// intersect scopes
scopes := make(map[string]bool)
for s := range s1Scopes {
// if s1 scope is included in s2 wilcards or s2 scopes
// then it is included in the intersection
if includes(s2Wildcards, s) || s2Scopes[s] {
scopes[s] = true
}
}
for s := range s2Scopes {
// if s2 scope is included in s1 wilcards
// then it is included in the intersection
if includes(s1Wildcards, s) {
scopes[s] = true
}
}
// merge wildcards and scopes
for w := range wildcards {
res = append(res, w)
}
for s := range scopes {
res = append(res, s)
}
return res
}
// Intersect returns the intersection of two slices of permissions, grouping scopes by action.
func Intersect(p1, p2 []Permission) map[string][]string {
if len(p1) == 0 || len(p2) == 0 {
return map[string][]string{}
}
res := make(map[string][]string)
p1m := Reduce(p1)
p2m := Reduce(p2)
// Loop over the smallest map
if len(p1m) > len(p2m) {
p1m, p2m = p2m, p1m
}
for a1, s1 := range p1m {
if s2, ok := p2m[a1]; ok {
res[a1] = intersectScopes(s1, s2)
}
}
return res
}
func ValidateScope(scope string) bool {
prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
// verify that last char is either ':' or '/' if last character of scope is '*'

View File

@ -1,6 +1,7 @@
package accesscontrol
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
@ -125,3 +126,210 @@ func TestReduce(t *testing.T) {
})
}
}
func TestIntersect(t *testing.T) {
tests := []struct {
name string
p1 []Permission
p2 []Permission
want map[string][]string
}{
{
name: "no permission",
p1: []Permission{},
p2: []Permission{},
want: map[string][]string{},
},
{
name: "no intersection",
p1: []Permission{{Action: "orgs:read"}},
p2: []Permission{{Action: "orgs:write"}},
want: map[string][]string{},
},
{
name: "intersection no scopes",
p1: []Permission{{Action: "orgs:read"}},
p2: []Permission{{Action: "orgs:read"}},
want: map[string][]string{"orgs:read": {}},
},
{
name: "unbalanced intersection",
p1: []Permission{{Action: "teams:read", Scope: "teams:id:1"}},
p2: []Permission{{Action: "teams:read"}},
want: map[string][]string{"teams:read": {}},
},
{
name: "intersection",
p1: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:2"},
{Action: "teams:write", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:3"},
{Action: "teams:write", Scope: "teams:id:1"},
},
want: map[string][]string{
"teams:read": {"teams:id:1"},
"teams:write": {"teams:id:1"},
},
},
{
name: "intersection with wildcards",
p1: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:2"},
{Action: "teams:write", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "teams:read", Scope: "*"},
{Action: "teams:write", Scope: "*"},
},
want: map[string][]string{
"teams:read": {"teams:id:1", "teams:id:2"},
"teams:write": {"teams:id:1"},
},
},
{
name: "intersection with wildcards on both sides",
p1: []Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:1"},
{Action: "dashboards:read", Scope: "folders:uid:1"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:1"},
},
p2: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:*"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:*"},
},
want: map[string][]string{
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
"folders:read": {"folders:uid:1"},
},
},
{
name: "intersection with wildcards of different sizes",
p1: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:1"},
{Action: "dashboards:read", Scope: "dashboards:*"},
{Action: "folders:read", Scope: "folders:*"},
{Action: "teams:read", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:*"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:*"},
{Action: "teams:read", Scope: "*"},
},
want: map[string][]string{
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
"folders:read": {"folders:uid:*"},
"teams:read": {"teams:id:1"},
},
},
}
check := func(t *testing.T, want map[string][]string, p1, p2 []Permission) {
intersect := Intersect(p1, p2)
for action, scopes := range intersect {
want, ok := want[action]
require.True(t, ok)
require.ElementsMatch(t, scopes, want, fmt.Sprintf("scopes for %v differs from expected", action))
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Intersect is commutative
check(t, tt.want, tt.p1, tt.p2)
check(t, tt.want, tt.p2, tt.p1)
})
}
}
func Test_intersectScopes(t *testing.T) {
tests := []struct {
name string
s1 []string
s2 []string
want []string
}{
{
name: "no values",
s1: []string{},
s2: []string{},
want: []string{},
},
{
name: "no values on one side",
s1: []string{},
s2: []string{"teams:id:1"},
want: []string{},
},
{
name: "empty values on one side",
s1: []string{""},
s2: []string{"team:id:1"},
want: []string{},
},
{
name: "no intersection",
s1: []string{"teams:id:1"},
s2: []string{"teams:id:2"},
want: []string{},
},
{
name: "intersection",
s1: []string{"teams:id:1"},
s2: []string{"teams:id:1"},
want: []string{"teams:id:1"},
},
{
name: "intersection with wildcard",
s1: []string{"teams:id:1", "teams:id:2"},
s2: []string{"teams:id:*"},
want: []string{"teams:id:1", "teams:id:2"},
},
{
name: "intersection of wildcards",
s1: []string{"teams:id:*"},
s2: []string{"teams:id:*"},
want: []string{"teams:id:*"},
},
{
name: "intersection with a bigger wildcards",
s1: []string{"teams:id:*"},
s2: []string{"teams:*"},
want: []string{"teams:id:*"},
},
{
name: "intersection of different wildcards with a bigger one",
s1: []string{"dashboards:uid:*", "folders:uid:*"},
s2: []string{"*"},
want: []string{"dashboards:uid:*", "folders:uid:*"},
},
{
name: "intersection with wildcards and scopes on both sides",
s1: []string{"dashboards:uid:*", "folders:uid:1"},
s2: []string{"folders:uid:*", "dashboards:uid:1"},
want: []string{"dashboards:uid:1", "folders:uid:1"},
},
{
name: "intersection of non reduced list of scopes",
s1: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1"},
s2: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:2"},
want: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1", "dashboards:uid:2"},
},
}
check := func(t *testing.T, want []string, s1, s2 []string) {
intersect := intersectScopes(s1, s2)
require.ElementsMatch(t, want, intersect)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Intersect is commutative
check(t, tt.want, tt.s1, tt.s2)
check(t, tt.want, tt.s2, tt.s1)
})
}
}

View File

@ -58,6 +58,7 @@ func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheSer
return s
}
//go:generate mockery --name store --structname MockStore --outpkg actest --filename store_mock.go --output ../actest/
type store interface {
GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error)
SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error)

View File

@ -0,0 +1,151 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package actest
import (
accesscontrol "github.com/grafana/grafana/pkg/services/accesscontrol"
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockStore is an autogenerated mock type for the store type
type MockStore struct {
mock.Mock
}
// DeleteExternalServiceRole provides a mock function with given fields: ctx, externalServiceID
func (_m *MockStore) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error {
ret := _m.Called(ctx, externalServiceID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, externalServiceID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteUserPermissions provides a mock function with given fields: ctx, orgID, userID
func (_m *MockStore) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error {
ret := _m.Called(ctx, orgID, userID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok {
r0 = rf(ctx, orgID, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetUserPermissions provides a mock function with given fields: ctx, query
func (_m *MockStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) {
ret := _m.Called(ctx, query)
var r0 []accesscontrol.Permission
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error)); ok {
return rf(ctx, query)
}
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.GetUserPermissionsQuery) []accesscontrol.Permission); ok {
r0 = rf(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]accesscontrol.Permission)
}
}
if rf, ok := ret.Get(1).(func(context.Context, accesscontrol.GetUserPermissionsQuery) error); ok {
r1 = rf(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUsersBasicRoles provides a mock function with given fields: ctx, userFilter, orgID
func (_m *MockStore) GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) {
ret := _m.Called(ctx, userFilter, orgID)
var r0 map[int64][]string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []int64, int64) (map[int64][]string, error)); ok {
return rf(ctx, userFilter, orgID)
}
if rf, ok := ret.Get(0).(func(context.Context, []int64, int64) map[int64][]string); ok {
r0 = rf(ctx, userFilter, orgID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64][]string)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []int64, int64) error); ok {
r1 = rf(ctx, userFilter, orgID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SaveExternalServiceRole provides a mock function with given fields: ctx, cmd
func (_m *MockStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
ret := _m.Called(ctx, cmd)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.SaveExternalServiceRoleCommand) error); ok {
r0 = rf(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// SearchUsersPermissions provides a mock function with given fields: ctx, orgID, options
func (_m *MockStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
ret := _m.Called(ctx, orgID, options)
var r0 map[int64][]accesscontrol.Permission
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error)); ok {
return rf(ctx, orgID, options)
}
if rf, ok := ret.Get(0).(func(context.Context, int64, accesscontrol.SearchOptions) map[int64][]accesscontrol.Permission); ok {
r0 = rf(ctx, orgID, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64][]accesscontrol.Permission)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64, accesscontrol.SearchOptions) error); ok {
r1 = rf(ctx, orgID, options)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewMockStore interface {
mock.TestingT
Cleanup(func())
}
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockStore(t mockConstructorTestingTNewMockStore) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -316,8 +316,10 @@ const (
ActionAPIKeyDelete = "apikeys:delete"
// Users actions
ActionUsersRead = "users:read"
ActionUsersWrite = "users:write"
ActionUsersRead = "users:read"
ActionUsersWrite = "users:write"
ActionUsersImpersonate = "users:impersonate"
// We can ignore gosec G101 since this does not contain any credentials.
// nolint:gosec
ActionUsersAuthTokenList = "users.authtoken:read"
@ -375,7 +377,8 @@ const (
ScopeAPIKeysAll = "apikeys:*"
// Users scope
ScopeUsersAll = "users:*"
ScopeUsersAll = "users:*"
ScopeUsersPrefix = "users:id:"
// Settings scope
ScopeSettingsAll = "settings:*"

View File

@ -27,6 +27,7 @@ import (
"github.com/grafana/grafana/pkg/services/ldap/service"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/loginattempt"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota"
@ -64,7 +65,7 @@ func ProvideService(
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
socialService social.Service, cache *remotecache.RemoteCache,
ldapService service.LDAP, registerer prometheus.Registerer,
signingKeysService signingkeys.Service,
signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server,
) authn.Service {
s := &Service{
log: log.New("authn.service"),
@ -131,7 +132,7 @@ func ProvideService(
}
if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService))
s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
}
for name := range socialService.GetOAuthProviders() {

View File

@ -2,6 +2,7 @@ package clients
import (
"context"
"strings"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/util/errutil"
@ -39,6 +40,13 @@ func (c *Basic) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
}
func (c *Basic) Test(ctx context.Context, r *authn.Request) bool {
if r.HTTPRequest == nil {
return false
}
// The OAuth2 introspection endpoint uses basic auth but is handled by the oauthserver package.
if strings.HasPrefix(r.HTTPRequest.RequestURI, "/oauth2/introspect") {
return false
}
return looksLikeBasicAuthRequest(r)
}

View File

@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@ -30,12 +31,13 @@ const (
rfc9068MediaType = "application/at+jwt"
)
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service) *ExtendedJWT {
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service, oauthServer oauthserver.OAuth2Server) *ExtendedJWT {
return &ExtendedJWT{
cfg: cfg,
log: log.New(authn.ClientExtendedJWT),
userService: userService,
signingKeys: signingKeys,
oauthServer: oauthServer,
}
}
@ -44,6 +46,7 @@ type ExtendedJWT struct {
log log.Logger
userService user.Service
signingKeys signingkeys.Service
oauthServer oauthserver.OAuth2Server
}
type ExtendedJWTClaims struct {
@ -211,10 +214,9 @@ func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims Extended
return fmt.Errorf("missing 'client_id' claim")
}
// TODO: Implement the validation for client_id when the OAuth server is ready.
// if _, err := s.oauthService.GetExternalService(ctx, clientId); err != nil {
// return fmt.Errorf("invalid 'client_id' claim: %s", clientIdClaim)
// }
if _, err := s.oauthServer.GetExternalService(ctx, claims.ClientID); err != nil {
return fmt.Errorf("invalid 'client_id' claim: %s", claims.ClientID)
}
return nil
}

View File

@ -15,6 +15,8 @@ import (
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/oastest"
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
@ -49,7 +51,7 @@ var (
pk, _ = rsa.GenerateKey(rand.Reader, 4096)
)
func TestExtendedJWTTest(t *testing.T) {
func TestExtendedJWT_Test(t *testing.T) {
type testCase struct {
name string
cfg *setting.Cfg
@ -105,7 +107,7 @@ func TestExtendedJWTTest(t *testing.T) {
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
extJwtClient := setupTestCtx(t, nil, tc.cfg)
env := setupTestCtx(t, tc.cfg)
validHTTPReq := &http.Request{
Header: map[string][]string{
@ -113,7 +115,7 @@ func TestExtendedJWTTest(t *testing.T) {
},
}
actual := extJwtClient.Test(context.Background(), &authn.Request{
actual := env.s.Test(context.Background(), &authn.Request{
HTTPRequest: validHTTPReq,
Resp: nil,
})
@ -123,22 +125,22 @@ func TestExtendedJWTTest(t *testing.T) {
}
}
func TestExtendedJWTAuthenticate(t *testing.T) {
func TestExtendedJWT_Authenticate(t *testing.T) {
type testCase struct {
name string
payload ExtendedJWTClaims
orgID int64
want *authn.Identity
userSvcSetup func(userSvc *usertest.FakeUserService)
wantErr bool
name string
payload ExtendedJWTClaims
orgID int64
want *authn.Identity
initTestEnv func(env *testEnv)
wantErr bool
}
testCases := []testCase{
{
name: "successful authentication",
payload: validPayload,
orgID: 1,
userSvcSetup: func(userSvc *usertest.FakeUserService) {
userSvc.ExpectedSignedInUser = &user.SignedInUser{
initTestEnv: func(env *testEnv) {
env.userSvc.ExpectedSignedInUser = &user.SignedInUser{
UserID: 2,
OrgID: 1,
OrgRole: roletype.RoleAdmin,
@ -242,8 +244,8 @@ func TestExtendedJWTAuthenticate(t *testing.T) {
},
orgID: 1,
want: nil,
userSvcSetup: func(userSvc *usertest.FakeUserService) {
userSvc.ExpectedError = user.ErrUserNotFound
initTestEnv: func(env *testEnv) {
env.userSvc.ExpectedError = user.ErrUserNotFound
},
wantErr: true,
},
@ -265,33 +267,34 @@ func TestExtendedJWTAuthenticate(t *testing.T) {
want: nil,
wantErr: true,
},
// {
// name: "should return error when the entitlements are not in the correct format",
// payload: ExtendedJWTClaims{
// Claims: jwt.Claims{
// Issuer: "http://localhost:3000",
// Subject: "user:id:2",
// Audience: jwt.Audience{"http://localhost:3000"},
// ID: "1234567890",
// Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
// IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
// },
// ClientID: "grafana",
// Scopes: []string{"profile", "groups"},
// Entitlements: []string{"dashboards:create", "folders:read"},
// },
// orgID: 1,
// want: nil,
// wantErr: true,
// },
{
name: "should return error when the client was not found",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "unknown-client-id",
Scopes: []string{"profile", "groups"},
},
initTestEnv: func(env *testEnv) {
env.oauthSvc.ExpectedErr = oauthserver.ErrClientNotFound("unknown-client-id")
},
orgID: 1,
want: nil,
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
userSvc := &usertest.FakeUserService{}
extJwtClient := setupTestCtx(t, userSvc, nil)
if tc.userSvcSetup != nil {
tc.userSvcSetup(userSvc)
env := setupTestCtx(t, nil)
if tc.initTestEnv != nil {
tc.initTestEnv(env)
}
validHTTPReq := &http.Request{
@ -302,7 +305,7 @@ func TestExtendedJWTAuthenticate(t *testing.T) {
mockTimeNow(time.Date(2023, 5, 2, 0, 1, 0, 0, time.UTC))
id, err := extJwtClient.Authenticate(context.Background(), &authn.Request{
id, err := env.s.Authenticate(context.Background(), &authn.Request{
OrgID: tc.orgID,
HTTPRequest: validHTTPReq,
Resp: nil,
@ -487,7 +490,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
},
}
extJwtClient := setupTestCtx(t, nil, nil)
env := setupTestCtx(t, nil)
mockTimeNow(time.Date(2023, 5, 2, 0, 1, 0, 0, time.UTC))
for _, tc := range testCases {
@ -496,13 +499,13 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) {
tc.alg = jose.RS256
}
tokenToTest := generateToken(tc.payload, pk, tc.alg)
_, err := extJwtClient.verifyRFC9068Token(context.Background(), tokenToTest)
_, err := env.s.verifyRFC9068Token(context.Background(), tokenToTest)
require.Error(t, err)
})
}
}
func setupTestCtx(t *testing.T, userSvc user.Service, cfg *setting.Cfg) *ExtendedJWT {
func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv {
if cfg == nil {
cfg = &setting.Cfg{
ExtendedJWTAuthEnabled: true,
@ -514,8 +517,22 @@ func setupTestCtx(t *testing.T, userSvc user.Service, cfg *setting.Cfg) *Extende
signingKeysSvc := &signingkeystest.FakeSigningKeysService{}
signingKeysSvc.ExpectedServerPublicKey = &pk.PublicKey
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc)
return extJwtClient
userSvc := &usertest.FakeUserService{}
oauthSvc := &oastest.FakeService{}
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc, oauthSvc)
return &testEnv{
oauthSvc: oauthSvc,
userSvc: userSvc,
s: extJwtClient,
}
}
type testEnv struct {
oauthSvc *oastest.FakeService
userSvc *usertest.FakeUserService
s *ExtendedJWT
}
func generateToken(payload ExtendedJWTClaims, signingKey interface{}, alg jose.SignatureAlgorithm) string {

View File

@ -0,0 +1,37 @@
package api
import (
"github.com/grafana/grafana/pkg/api/routing"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/oauthserver"
)
type api struct {
router routing.RouteRegister
oauthServer oauthserver.OAuth2Server
}
func NewAPI(
router routing.RouteRegister,
oauthServer oauthserver.OAuth2Server,
) *api {
return &api{
router: router,
oauthServer: oauthServer,
}
}
func (a *api) RegisterAPIEndpoints() {
a.router.Group("/oauth2", func(oauthRouter routing.RouteRegister) {
oauthRouter.Post("/introspect", a.handleIntrospectionRequest)
oauthRouter.Post("/token", a.handleTokenRequest)
})
}
func (a *api) handleTokenRequest(c *contextmodel.ReqContext) {
a.oauthServer.HandleTokenRequest(c.Resp, c.Req)
}
func (a *api) handleIntrospectionRequest(c *contextmodel.ReqContext) {
a.oauthServer.HandleIntrospectionRequest(c.Resp, c.Req)
}

View File

@ -0,0 +1,27 @@
package oauthserver
import (
"fmt"
"github.com/grafana/grafana/pkg/util/errutil"
)
var (
ErrClientNotFoundMessageID = "oauthserver.client-not-found"
)
var (
ErrClientRequiredID = errutil.NewBase(errutil.StatusBadRequest,
"oauthserver.required-client-id",
errutil.WithPublicMessage("client ID is required")).Errorf("Client ID is required")
ErrClientRequiredName = errutil.NewBase(errutil.StatusBadRequest,
"oauthserver.required-client-name",
errutil.WithPublicMessage("client name is required")).Errorf("Client name is required")
)
func ErrClientNotFound(clientID string) error {
return errutil.NewBase(errutil.StatusNotFound,
ErrClientNotFoundMessageID,
errutil.WithPublicMessage(fmt.Sprintf("Client '%s' not found", clientID))).
Errorf("client '%s' not found", clientID)
}

View File

@ -0,0 +1,161 @@
package oauthserver
import (
"context"
"strconv"
"strings"
"github.com/ory/fosite"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/user"
)
type KeyResult struct {
URL string `json:"url,omitempty"`
PrivatePem string `json:"private,omitempty"`
PublicPem string `json:"public,omitempty"`
Generated bool `json:"generated,omitempty"`
}
type ExternalServiceDTO struct {
Name string `json:"name"`
ID string `json:"clientId"`
Secret string `json:"clientSecret"`
RedirectURI string `json:"redirectUri,omitempty"` // Not used yet (code flow)
GrantTypes string `json:"grantTypes"` // CSV value
Audiences string `json:"audiences"` // CSV value
KeyResult *KeyResult `json:"key,omitempty"`
}
type ExternalService struct {
ID int64 `xorm:"id pk autoincr"`
Name string `xorm:"name"`
ClientID string `xorm:"client_id"`
Secret string `xorm:"secret"`
RedirectURI string `xorm:"redirect_uri"` // Not used yet (code flow)
GrantTypes string `xorm:"grant_types"` // CSV value
Audiences string `xorm:"audiences"` // CSV value
PublicPem []byte `xorm:"public_pem"`
ServiceAccountID int64 `xorm:"service_account_id"`
// SelfPermissions are the registered service account permissions (registered and managed permissions)
SelfPermissions []ac.Permission
// ImpersonatePermissions is the restriction set of permissions while impersonating
ImpersonatePermissions []ac.Permission
// SignedInUser refers to the current Service Account identity/user
SignedInUser *user.SignedInUser
Scopes []string
ImpersonateScopes []string
}
func (c *ExternalService) ToDTO() *ExternalServiceDTO {
c2 := ExternalServiceDTO{
Name: c.Name,
ID: c.ClientID,
Secret: c.Secret,
GrantTypes: c.GrantTypes,
Audiences: c.Audiences,
RedirectURI: c.RedirectURI,
}
if len(c.PublicPem) > 0 {
c2.KeyResult = &KeyResult{PublicPem: string(c.PublicPem)}
}
return &c2
}
func (c *ExternalService) LogID() string {
return "{name: " + c.Name + ", clientID: " + c.ClientID + "}"
}
// GetID returns the client ID.
func (c *ExternalService) GetID() string { return c.ClientID }
// GetHashedSecret returns the hashed secret as it is stored in the store.
func (c *ExternalService) GetHashedSecret() []byte {
// Hashed version is stored in the secret field
return []byte(c.Secret)
}
// GetRedirectURIs returns the client's allowed redirect URIs.
func (c *ExternalService) GetRedirectURIs() []string {
return []string{c.RedirectURI}
}
// GetGrantTypes returns the client's allowed grant types.
func (c *ExternalService) GetGrantTypes() fosite.Arguments {
return strings.Split(c.GrantTypes, ",")
}
// GetResponseTypes returns the client's allowed response types.
// All allowed combinations of response types have to be listed, each combination having
// response types of the combination separated by a space.
func (c *ExternalService) GetResponseTypes() fosite.Arguments {
return fosite.Arguments{"code"}
}
// GetScopes returns the scopes this client is allowed to request on its own behalf.
func (c *ExternalService) GetScopes() fosite.Arguments {
if c.Scopes != nil {
return c.Scopes
}
ret := []string{"profile", "email", "groups", "entitlements"}
if c.SignedInUser != nil && c.SignedInUser.Permissions != nil {
perms := c.SignedInUser.Permissions[TmpOrgID]
for action := range perms {
// Add all actions that the plugin is allowed to request
ret = append(ret, action)
}
}
c.Scopes = ret
return ret
}
// GetScopes returns the scopes this client is allowed to request on a specific user.
func (c *ExternalService) GetScopesOnUser(ctx context.Context, accessControl ac.AccessControl, userID int64) []string {
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
hasAccess, errAccess := accessControl.Evaluate(ctx, c.SignedInUser, ev)
if errAccess != nil || !hasAccess {
return nil
}
if c.ImpersonateScopes != nil {
return c.ImpersonateScopes
}
ret := []string{}
if c.ImpersonatePermissions != nil {
perms := c.ImpersonatePermissions
for i := range perms {
if perms[i].Action == ac.ActionUsersRead && perms[i].Scope == ScopeGlobalUsersSelf {
ret = append(ret, "profile", "email", ac.ActionUsersRead)
continue
}
if perms[i].Action == ac.ActionUsersPermissionsRead && perms[i].Scope == ScopeUsersSelf {
ret = append(ret, "entitlements", ac.ActionUsersPermissionsRead)
continue
}
if perms[i].Action == ac.ActionTeamsRead && perms[i].Scope == ScopeTeamsSelf {
ret = append(ret, "groups", ac.ActionTeamsRead)
continue
}
// Add all actions that the plugin is allowed to request
ret = append(ret, perms[i].Action)
}
}
c.ImpersonateScopes = ret
return ret
}
// IsPublic returns true, if this client is marked as public.
func (c *ExternalService) IsPublic() bool {
return false
}
// GetAudience returns the allowed audience(s) for this client.
func (c *ExternalService) GetAudience() fosite.Arguments {
return strings.Split(c.Audiences, ",")
}

View File

@ -0,0 +1,210 @@
package oauthserver
import (
"context"
"testing"
"github.com/stretchr/testify/require"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func setupTestEnv(t *testing.T) *ExternalService {
t.Helper()
client := &ExternalService{
Name: "my-ext-service",
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer",
ServiceAccountID: 2,
SelfPermissions: []ac.Permission{
{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll},
},
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: 1,
},
}
return client
}
func TestExternalService_GetScopesOnUser(t *testing.T) {
testCases := []struct {
name string
impersonatePermissions []ac.Permission
initTestEnv func(*ExternalService)
expectedScopes []string
}{
{
name: "should return nil when the service account has no impersonate permissions",
expectedScopes: nil,
},
{
name: "should return the 'profile', 'email' and associated RBAC action",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf},
}
},
expectedScopes: []string{"profile", "email", ac.ActionUsersRead},
},
{
name: "should return 'entitlements' and associated RBAC action scopes",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf},
}
},
expectedScopes: []string{"entitlements", ac.ActionUsersPermissionsRead},
},
{
name: "should return 'groups' and associated RBAC action scopes",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf},
}
},
expectedScopes: []string{"groups", ac.ActionTeamsRead},
},
{
name: "should return all scopes",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf},
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf},
{Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll},
}
},
expectedScopes: []string{"profile", "email", ac.ActionUsersRead,
"entitlements", ac.ActionUsersPermissionsRead,
"groups", ac.ActionTeamsRead,
"dashboards:read"},
},
{
name: "should return stored scopes when the client's impersonate scopes has already been set",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonateScopes = []string{"dashboard:create", "profile", "email", "entitlements", "groups"}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboard:create"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(c)
}
scopes := c.GetScopesOnUser(context.Background(), acimpl.ProvideAccessControl(setting.NewCfg()), 3)
require.ElementsMatch(t, tc.expectedScopes, scopes)
})
}
}
func TestExternalService_GetScopes(t *testing.T) {
testCases := []struct {
name string
impersonatePermissions []ac.Permission
initTestEnv func(*ExternalService)
expectedScopes []string
}{
{
name: "should return default scopes when the signed in user is nil",
initTestEnv: func(c *ExternalService) {
c.SignedInUser = nil
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
{
name: "should return default scopes when the signed in user has no permissions",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
{
name: "should return additional scopes from signed in user's permissions",
initTestEnv: func(c *ExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll},
},
}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboards:read"},
},
{
name: "should return stored scopes when the client's scopes has already been set",
initTestEnv: func(c *ExternalService) {
c.Scopes = []string{"profile", "email", "entitlements", "groups"}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(c)
}
scopes := c.GetScopes()
require.ElementsMatch(t, tc.expectedScopes, scopes)
})
}
}
func TestExternalService_ToDTO(t *testing.T) {
client := &ExternalService{
ID: 1,
Name: "my-ext-service",
ClientID: "test",
Secret: "testsecret",
RedirectURI: "http://localhost:3000",
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer",
Audiences: "https://example.org,https://second.example.org",
PublicPem: []byte("pem_encoded_public_key"),
}
dto := client.ToDTO()
require.Equal(t, client.ClientID, dto.ID)
require.Equal(t, client.Name, dto.Name)
require.Equal(t, client.RedirectURI, dto.RedirectURI)
require.Equal(t, client.GrantTypes, dto.GrantTypes)
require.Equal(t, client.Audiences, dto.Audiences)
require.Equal(t, client.PublicPem, []byte(dto.KeyResult.PublicPem))
require.Empty(t, dto.KeyResult.PrivatePem)
require.Empty(t, dto.KeyResult.URL)
require.False(t, dto.KeyResult.Generated)
require.Equal(t, client.Secret, dto.Secret)
}

View File

@ -0,0 +1,91 @@
package oauthserver
import (
"context"
"net/http"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"gopkg.in/square/go-jose.v2"
)
const (
// TmpOrgID is the orgID we use while global service accounts are not supported.
TmpOrgID int64 = 1
// NoServiceAccountID is the ID we use for client that have no service account associated.
NoServiceAccountID int64 = 0
// List of scopes used to identify the impersonated user.
ScopeUsersSelf = "users:self"
ScopeGlobalUsersSelf = "global.users:self"
ScopeTeamsSelf = "teams:self"
// Supported encryptions
RS256 = "RS256"
ES256 = "ES256"
)
// OAuth2Server represents a service in charge of managing OAuth2 clients
// and handling OAuth2 requests (token, introspection).
type OAuth2Server interface {
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions.
SaveExternalService(ctx context.Context, cmd *ExternalServiceRegistration) (*ExternalServiceDTO, error)
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account.
GetExternalService(ctx context.Context, id string) (*ExternalService, error)
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer).
HandleTokenRequest(rw http.ResponseWriter, req *http.Request)
// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and
// to determine meta-information about this token.
HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request)
}
//go:generate mockery --name Store --structname MockStore --outpkg oauthtest --filename store_mock.go --output ./oauthtest/
type Store interface {
RegisterExternalService(ctx context.Context, client *ExternalService) error
SaveExternalService(ctx context.Context, client *ExternalService) error
GetExternalService(ctx context.Context, id string) (*ExternalService, error)
GetExternalServiceByName(ctx context.Context, name string) (*ExternalService, error)
GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error)
}
type KeyOption struct {
// URL string `json:"url,omitempty"` // TODO allow specifying a URL (to a .jwks file) to fetch the key from
// PublicPEM contains the Base64 encoded public key in PEM format
PublicPEM string `json:"public_pem,omitempty"`
Generate bool `json:"generate,omitempty"`
}
type SelfCfg struct {
// Enabled allows the service to request access tokens for itself using the client_credentials grant
Enabled bool `json:"enabled"`
// Permissions are the permissions that the external service needs its associated service account to have.
Permissions []accesscontrol.Permission `json:"permissions,omitempty"`
}
type ImpersonationCfg struct {
// Enabled allows the service to request access tokens to impersonate users using the jwtbearer grant
Enabled bool `json:"enabled"`
// Groups allows the service to list the impersonated user's teams
Groups bool `json:"groups"`
// Permissions are the permissions that the external service needs when impersonating a user.
// The intersection of this set with the impersonated user's permission guarantees that the client will not
// gain more privileges than the impersonated user has.
Permissions []accesscontrol.Permission `json:"permissions,omitempty"`
}
// ExternalServiceRegistration represents the registration form to save new OAuth2 client.
type ExternalServiceRegistration struct {
Name string `json:"name"`
// RedirectURI is the URI that is used in the code flow.
// Note that this is not used yet.
RedirectURI *string `json:"redirectUri,omitempty"`
// Impersonation access configuration
Impersonation ImpersonationCfg `json:"impersonation"`
// Self access configuration
Self SelfCfg `json:"self"`
// Key is the option to specify a public key or ask the server to generate a crypto key pair.
Key *KeyOption `json:"key,omitempty"`
}

View File

@ -0,0 +1,162 @@
package oasimpl
import (
"context"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/rfc7523"
"gopkg.in/square/go-jose.v2"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
)
var _ fosite.ClientManager = &OAuth2ServiceImpl{}
var _ oauth2.AuthorizeCodeStorage = &OAuth2ServiceImpl{}
var _ oauth2.AccessTokenStorage = &OAuth2ServiceImpl{}
var _ oauth2.RefreshTokenStorage = &OAuth2ServiceImpl{}
var _ rfc7523.RFC7523KeyStorage = &OAuth2ServiceImpl{}
var _ oauth2.TokenRevocationStorage = &OAuth2ServiceImpl{}
// GetClient loads the client by its ID or returns an error
// if the client does not exist or another error occurred.
func (s *OAuth2ServiceImpl) GetClient(ctx context.Context, id string) (fosite.Client, error) {
return s.GetExternalService(ctx, id)
}
// ClientAssertionJWTValid returns an error if the JTI is
// known or the DB check failed and nil if the JTI is not known.
func (s *OAuth2ServiceImpl) ClientAssertionJWTValid(ctx context.Context, jti string) error {
return s.memstore.ClientAssertionJWTValid(ctx, jti)
}
// SetClientAssertionJWT marks a JTI as known for the given
// expiry time. Before inserting the new JTI, it will clean
// up any existing JTIs that have expired as those tokens can
// not be replayed due to the expiry.
func (s *OAuth2ServiceImpl) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
return s.memstore.SetClientAssertionJWT(ctx, jti, exp)
}
// GetAuthorizeCodeSession stores the authorization request for a given authorization code.
func (s *OAuth2ServiceImpl) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error) {
return s.memstore.CreateAuthorizeCodeSession(ctx, code, request)
}
// GetAuthorizeCodeSession hydrates the session based on the given code and returns the authorization request.
// If the authorization code has been invalidated with `InvalidateAuthorizeCodeSession`, this
// method should return the ErrInvalidatedAuthorizeCode error.
//
// Make sure to also return the fosite.Requester value when returning the fosite.ErrInvalidatedAuthorizeCode error!
func (s *OAuth2ServiceImpl) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetAuthorizeCodeSession(ctx, code, session)
}
// InvalidateAuthorizeCodeSession is called when an authorize code is being used. The state of the authorization
// code should be set to invalid and consecutive requests to GetAuthorizeCodeSession should return the
// ErrInvalidatedAuthorizeCode error.
func (s *OAuth2ServiceImpl) InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error) {
return s.memstore.InvalidateAuthorizeCodeSession(ctx, code)
}
func (s *OAuth2ServiceImpl) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return s.memstore.CreateAccessTokenSession(ctx, signature, request)
}
func (s *OAuth2ServiceImpl) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetAccessTokenSession(ctx, signature, session)
}
func (s *OAuth2ServiceImpl) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
return s.memstore.DeleteAccessTokenSession(ctx, signature)
}
func (s *OAuth2ServiceImpl) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return s.memstore.CreateRefreshTokenSession(ctx, signature, request)
}
func (s *OAuth2ServiceImpl) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetRefreshTokenSession(ctx, signature, session)
}
func (s *OAuth2ServiceImpl) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) {
return s.memstore.DeleteRefreshTokenSession(ctx, signature)
}
// RevokeRefreshToken revokes a refresh token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the particular
// token is a refresh token and the authorization server supports the
// revocation of access tokens, then the authorization server SHOULD
// also invalidate all access tokens based on the same authorization
// grant (see Implementation Note).
func (s *OAuth2ServiceImpl) RevokeRefreshToken(ctx context.Context, requestID string) error {
return s.memstore.RevokeRefreshToken(ctx, requestID)
}
// RevokeRefreshTokenMaybeGracePeriod revokes a refresh token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the particular
// token is a refresh token and the authorization server supports the
// revocation of access tokens, then the authorization server SHOULD
// also invalidate all access tokens based on the same authorization
// grant (see Implementation Note).
//
// If the Refresh Token grace period is greater than zero in configuration the token
// will have its expiration time set as UTCNow + GracePeriod.
func (s *OAuth2ServiceImpl) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error {
return s.memstore.RevokeRefreshTokenMaybeGracePeriod(ctx, requestID, signature)
}
// RevokeAccessToken revokes an access token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the token passed to the request
// is an access token, the server MAY revoke the respective refresh
// token as well.
func (s *OAuth2ServiceImpl) RevokeAccessToken(ctx context.Context, requestID string) error {
return s.memstore.RevokeAccessToken(ctx, requestID)
}
// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check
// signature of jwt assertion in authorization grants.
func (s *OAuth2ServiceImpl) GetPublicKey(ctx context.Context, issuer string, subject string, kid string) (*jose.JSONWebKey, error) {
return s.sqlstore.GetExternalServicePublicKey(ctx, issuer)
}
// GetPublicKeys returns public key, set issued by 'issuer', and assigned for subject.
func (s *OAuth2ServiceImpl) GetPublicKeys(ctx context.Context, issuer string, subject string) (*jose.JSONWebKeySet, error) {
jwk, err := s.sqlstore.GetExternalServicePublicKey(ctx, issuer)
if err != nil {
return nil, err
}
return &jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{*jwk},
}, nil
}
// GetPublicKeyScopes returns assigned scope for assertion, identified by public key, issued by 'issuer'.
func (s *OAuth2ServiceImpl) GetPublicKeyScopes(ctx context.Context, issuer string, subject string, kid string) ([]string, error) {
client, err := s.GetExternalService(ctx, issuer)
if err != nil {
return nil, err
}
userID, err := utils.ParseUserIDFromSubject(subject)
if err != nil {
return nil, err
}
return client.GetScopesOnUser(ctx, s.accessControl, userID), nil
}
// IsJWTUsed returns true, if JWT is not known yet or it can not be considered valid, because it must be already
// expired.
func (s *OAuth2ServiceImpl) IsJWTUsed(ctx context.Context, jti string) (bool, error) {
return s.memstore.IsJWTUsed(ctx, jti)
}
// MarkJWTUsedForTime marks JWT as used for a time passed in exp parameter. This helps ensure that JWTs are not
// replayed by maintaining the set of used "jti" values for the length of time for which the JWT would be
// considered valid based on the applicable "exp" instant. (https://tools.ietf.org/html/rfc7523#section-3)
func (s *OAuth2ServiceImpl) MarkJWTUsedForTime(ctx context.Context, jti string, exp time.Time) error {
return s.memstore.MarkJWTUsedForTime(ctx, jti, exp)
}

View File

@ -0,0 +1,119 @@
package oasimpl
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/user"
)
var cachedExternalService = func() *oauthserver.ExternalService {
return &oauthserver.ExternalService{
Name: "my-ext-service",
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
"users:impersonate": {"users:*"},
},
},
},
}
}
func TestOAuth2ServiceImpl_GetPublicKeyScopes(t *testing.T) {
testCases := []struct {
name string
initTestEnv func(*TestEnv)
impersonatePermissions []ac.Permission
userID string
expectedScopes []string
wantErr bool
}{
{
name: "should error out when GetExternalService returns error",
initTestEnv: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFound("my-ext-service"))
},
wantErr: true,
},
{
name: "should error out when the user id cannot be parsed",
initTestEnv: func(env *TestEnv) {
env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute)
},
userID: "user:3",
wantErr: true,
},
{
name: "should return no scope when the external service is not allowed to impersonate the user",
initTestEnv: func(env *TestEnv) {
client := cachedExternalService()
client.SignedInUser.Permissions = map[int64]map[string][]string{}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: nil,
wantErr: false,
},
{
name: "should return no scope when the external service has an no impersonate permission",
initTestEnv: func(env *TestEnv) {
client := cachedExternalService()
client.ImpersonatePermissions = []ac.Permission{}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: []string{},
wantErr: false,
},
{
name: "should return the scopes when the external service has impersonate permissions",
initTestEnv: func(env *TestEnv) {
env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute)
client := cachedExternalService()
client.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll},
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf}}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: []string{"users:impersonate",
"profile", "email", ac.ActionUsersRead,
"entitlements", ac.ActionUsersPermissionsRead,
"groups", ac.ActionTeamsRead},
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
env := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(env)
}
scopes, err := env.S.GetPublicKeyScopes(context.Background(), "my-ext-service", tc.userID, "")
if tc.wantErr {
require.Error(t, err)
return
}
require.EqualValues(t, tc.expectedScopes, scopes)
})
}
}

View File

@ -0,0 +1,21 @@
package oasimpl
import (
"log"
"net/http"
)
// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and
// to determine meta-information about this token
func (s *OAuth2ServiceImpl) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
currentOAuthSessionData := NewAuthSession()
ir, err := s.oauthProvider.NewIntrospectionRequest(ctx, req, currentOAuthSessionData)
if err != nil {
log.Printf("Error occurred in NewIntrospectionRequest: %+v", err)
s.oauthProvider.WriteIntrospectionError(ctx, rw, err)
return
}
s.oauthProvider.WriteIntrospectionResponse(ctx, rw, ir)
}

View File

@ -0,0 +1,501 @@
package oasimpl
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/storage"
"github.com/ory/fosite/token/jwt"
"golang.org/x/crypto/bcrypt"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/models/roletype"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/api"
"github.com/grafana/grafana/pkg/services/oauthserver/store"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
)
const (
cacheExpirationTime = 5 * time.Minute
cacheCleanupInterval = 5 * time.Minute
)
type OAuth2ServiceImpl struct {
cache *localcache.CacheService
memstore *storage.MemoryStore
cfg *setting.Cfg
sqlstore oauthserver.Store
oauthProvider fosite.OAuth2Provider
logger log.Logger
accessControl ac.AccessControl
acService ac.Service
saService serviceaccounts.Service
userService user.Service
teamService team.Service
publicKey interface{}
}
func ProvideService(router routing.RouteRegister, db db.DB, cfg *setting.Cfg, skv kvstore.SecretsKVStore,
svcAccSvc serviceaccounts.Service, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service,
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) {
if !fmgmt.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
return nil, nil
}
config := &fosite.Config{
AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan,
TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL),
AccessTokenIssuer: cfg.AppURL,
IDTokenIssuer: cfg.AppURL,
ScopeStrategy: fosite.WildcardScopeStrategy,
}
privateKey := keySvc.GetServerPrivateKey()
var publicKey interface{}
switch k := privateKey.(type) {
case *rsa.PrivateKey:
publicKey = &k.PublicKey
case *ecdsa.PrivateKey:
publicKey = &k.PublicKey
default:
return nil, fmt.Errorf("unknown private key type %T", k)
}
s := &OAuth2ServiceImpl{
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
cfg: cfg,
accessControl: accessControl,
acService: acSvc,
memstore: storage.NewMemoryStore(),
sqlstore: store.NewStore(db),
logger: log.New("oauthserver"),
userService: userSvc,
saService: svcAccSvc,
teamService: teamSvc,
publicKey: publicKey,
}
api := api.NewAPI(router, s)
api.RegisterAPIEndpoints()
s.oauthProvider = newProvider(config, s, privateKey)
return s, nil
}
func newProvider(config *fosite.Config, storage interface{}, key interface{}) fosite.OAuth2Provider {
keyGetter := func(context.Context) (interface{}, error) {
return key, nil
}
return compose.Compose(
config,
storage,
&compose.CommonStrategy{
CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config),
Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter},
},
compose.OAuth2ClientCredentialsGrantFactory,
compose.RFC7523AssertionGrantFactory,
compose.OAuth2TokenIntrospectionFactory,
compose.OAuth2TokenRevocationFactory,
)
}
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account.
// For performance reason, the service uses caching.
func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
entry, ok := s.cache.Get(id)
if ok {
client, ok := entry.(oauthserver.ExternalService)
if ok {
s.logger.Debug("GetExternalService: cache hit", "id", id)
return &client, nil
}
}
client, err := s.sqlstore.GetExternalService(ctx, id)
if err != nil {
return nil, err
}
// Handle the case where the external service has no service account
if client.ServiceAccountID == oauthserver.NoServiceAccountID {
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "id", id, "name", client.Name)
// Create a signed in user with no role and no permissions
client.SignedInUser = &user.SignedInUser{
UserID: oauthserver.NoServiceAccountID,
OrgID: oauthserver.TmpOrgID,
Name: client.Name,
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
}
s.cache.Set(id, *client, cacheExpirationTime)
return client, nil
}
// Retrieve self permissions and generate a signed in user
s.logger.Debug("GetExternalService: fetch permissions", "client id", id)
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID)
if err != nil {
s.logger.Error("GetExternalService: error fetching service account", "id", id, "error", err)
return nil, err
}
client.SignedInUser = &user.SignedInUser{
UserID: sa.Id,
OrgID: oauthserver.TmpOrgID,
OrgRole: org.RoleType(sa.Role), // Need this to compute the permissions in OSS
Login: sa.Login,
Name: sa.Name,
Permissions: map[int64]map[string][]string{},
}
client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{})
if err != nil {
s.logger.Error("GetExternalService: error fetching permissions", "id", id, "error", err)
return nil, err
}
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions)
s.cache.Set(id, *client, cacheExpirationTime)
return client, nil
}
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions.
// Database consistency is not guaranteed, consider changing this in the future.
func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *oauthserver.ExternalServiceRegistration) (*oauthserver.ExternalServiceDTO, error) {
if registration == nil {
s.logger.Warn("RegisterExternalService called without registration")
return nil, nil
}
s.logger.Info("Registering external service", "external service name", registration.Name)
// Check if the client already exists in store
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, registration.Name)
if errFetchExtSvc != nil {
var srcError errutil.Error
if errors.As(errFetchExtSvc, &srcError) {
if srcError.MessageID != oauthserver.ErrClientNotFoundMessageID {
s.logger.Error("Error fetching service", "external service", registration.Name, "error", errFetchExtSvc)
return nil, errFetchExtSvc
}
}
}
// Otherwise, create a new client
if client == nil {
s.logger.Debug("External service does not yet exist", "external service name", registration.Name)
client = &oauthserver.ExternalService{
Name: registration.Name,
ServiceAccountID: oauthserver.NoServiceAccountID,
Audiences: s.cfg.AppURL,
}
}
// Parse registration form to compute required permissions for the client
client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration)
if registration.RedirectURI != nil {
client.RedirectURI = *registration.RedirectURI
}
var errGenCred error
client.ClientID, client.Secret, errGenCred = s.genCredentials()
if errGenCred != nil {
s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred)
return nil, errGenCred
}
s.logger.Debug("Save service account")
saID, errSaveServiceAccount := s.saveServiceAccount(ctx, client.Name, client.ServiceAccountID, client.SelfPermissions)
if errSaveServiceAccount != nil {
return nil, errSaveServiceAccount
}
client.ServiceAccountID = saID
grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled)
client.GrantTypes = strings.Join(grantTypes, ",")
// Handle key options
s.logger.Debug("Handle key options")
keys, err := s.handleKeyOptions(ctx, registration.Key)
if err != nil {
s.logger.Error("Error handling key options", "client", client.LogID(), "error", err)
return nil, err
}
if keys != nil {
client.PublicPem = []byte(keys.PublicPem)
}
dto := client.ToDTO()
dto.KeyResult = keys
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err)
return nil, err
}
client.Secret = string(hashedSecret)
err = s.sqlstore.SaveExternalService(ctx, client)
if err != nil {
s.logger.Error("Error saving external service", "client", client.LogID(), "error", err)
return nil, err
}
s.logger.Debug("Registered", "client", client.LogID())
return dto, nil
}
// randString generates a a cryptographically secure random string of n bytes
func (s *OAuth2ServiceImpl) randString(n int) (string, error) {
res := make([]byte, n)
if _, err := rand.Read(res); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(res), nil
}
func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) {
id, err := s.randString(20)
if err != nil {
return "", "", err
}
// client_secret must be at least 32 bytes long
secret, err := s.randString(32)
if err != nil {
return "", "", err
}
return id, secret, err
}
func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string {
grantTypes := []string{}
if selfAccessEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
}
if impersonationEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer))
}
return grantTypes
}
func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *oauthserver.KeyOption) (*oauthserver.KeyResult, error) {
if keyOption == nil {
return nil, fmt.Errorf("keyOption is nil")
}
var publicPem, privatePem string
if keyOption.Generate {
switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient {
case "RSA":
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}))
s.logger.Debug("RSA key has been generated")
default: // default to ECDSA
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicDer,
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateDer,
}))
s.logger.Debug("ECDSA key has been generated")
}
return &oauthserver.KeyResult{
PrivatePem: privatePem,
PublicPem: publicPem,
Generated: true,
}, nil
}
// TODO MVP allow specifying a URL to get the public key
// if registration.Key.URL != "" {
// return &oauthserver.KeyResult{
// URL: registration.Key.URL,
// }, nil
// }
if keyOption.PublicPEM != "" {
pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM)
if err != nil {
s.logger.Error("cannot decode base64 encoded PEM string", "error", err)
}
_, err = utils.ParsePublicKeyPem(pemEncoded)
if err != nil {
s.logger.Error("cannot parse PEM encoded string", "error", err)
return nil, err
}
return &oauthserver.KeyResult{
PublicPem: string(pemEncoded),
}, nil
}
return nil, fmt.Errorf("at least one key option must be specified")
}
// saveServiceAccount creates a service account if the service account ID is NoServiceAccountID, otherwise it updates the service account's permissions
func (s *OAuth2ServiceImpl) saveServiceAccount(ctx context.Context, extSvcName string, saID int64, permissions []ac.Permission) (int64, error) {
if saID == oauthserver.NoServiceAccountID {
// Create a service account
s.logger.Debug("Create service account", "external service name", extSvcName)
return s.createServiceAccount(ctx, extSvcName, permissions)
}
// check if the service account exists
s.logger.Debug("Update service account", "external service name", extSvcName)
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, saID)
if err != nil {
s.logger.Error("Error retrieving service account", "external service name", extSvcName, "error", err)
return oauthserver.NoServiceAccountID, err
}
// update the service account's permissions
if len(permissions) > 0 {
s.logger.Debug("Update role permissions", "external service name", extSvcName, "saID", saID)
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
OrgID: ac.GlobalOrgID,
Global: true,
ExternalServiceID: extSvcName,
ServiceAccountID: sa.Id,
Permissions: permissions,
}); err != nil {
return oauthserver.NoServiceAccountID, err
}
return saID, nil
}
// remove the service account
errDelete := s.deleteServiceAccount(ctx, extSvcName, sa.Id)
return oauthserver.NoServiceAccountID, errDelete
}
// deleteServiceAccount deletes a service account by ID and removes its associated role
func (s *OAuth2ServiceImpl) deleteServiceAccount(ctx context.Context, extSvcName string, saID int64) error {
s.logger.Debug("Delete service account", "external service name", extSvcName, "saID", saID)
if err := s.saService.DeleteServiceAccount(ctx, oauthserver.TmpOrgID, saID); err != nil {
return err
}
return s.acService.DeleteExternalServiceRole(ctx, extSvcName)
}
// createServiceAccount creates a service account with the given permissions and returns the ID of the service account
// When no permission is given, the account isn't created and NoServiceAccountID is returned
// This first design does not use a single transaction for the whole service account creation process => database consistency is not guaranteed.
// Consider changing this in the future.
func (s *OAuth2ServiceImpl) createServiceAccount(ctx context.Context, extSvcName string, permissions []ac.Permission) (int64, error) {
if len(permissions) == 0 {
// No permission, no service account
s.logger.Debug("No permission, no service account", "external service name", extSvcName)
return oauthserver.NoServiceAccountID, nil
}
newRole := func(r roletype.RoleType) *roletype.RoleType {
return &r
}
newBool := func(b bool) *bool {
return &b
}
slug := slugify.Slugify(extSvcName)
s.logger.Debug("Generate service account", "external service name", extSvcName, "orgID", oauthserver.TmpOrgID, "name", slug)
sa, err := s.saService.CreateServiceAccount(ctx, oauthserver.TmpOrgID, &serviceaccounts.CreateServiceAccountForm{
Name: slug,
Role: newRole(roletype.RoleViewer), // FIXME: Use empty role
IsDisabled: newBool(false),
})
if err != nil {
return oauthserver.NoServiceAccountID, err
}
s.logger.Debug("create tailored role for service account", "external service name", extSvcName, "name", slug, "service_account_id", sa.Id, "permissions", permissions)
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
OrgID: ac.GlobalOrgID,
Global: true,
ExternalServiceID: slug,
ServiceAccountID: sa.Id,
Permissions: permissions,
}); err != nil {
return oauthserver.NoServiceAccountID, err
}
return sa.Id, nil
}
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
// permissions when impersonation is requested
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *oauthserver.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) {
selfPermissions := []ac.Permission{}
impersonatePermissions := []ac.Permission{}
if registration.Self.Enabled {
selfPermissions = append(selfPermissions, registration.Self.Permissions...)
}
if registration.Impersonation.Enabled {
requiredForToken := []ac.Permission{
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
}
if registration.Impersonation.Groups {
requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
}
impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...)
selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll})
}
return selfPermissions, impersonatePermissions
}

View File

@ -0,0 +1,545 @@
package oasimpl
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/storage"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models/roletype"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/oastest"
sa "github.com/grafana/grafana/pkg/services/serviceaccounts"
satests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/grafana/grafana/pkg/services/team/teamtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
)
const (
AppURL = "https://oauth.test/"
TokenURL = AppURL + "oauth2/token"
)
var (
pk, _ = rsa.GenerateKey(rand.Reader, 4096)
Client1Key, _ = rsa.GenerateKey(rand.Reader, 4096)
)
type TestEnv struct {
S *OAuth2ServiceImpl
Cfg *setting.Cfg
AcStore *actest.MockStore
OAuthStore *oastest.MockStore
UserService *usertest.FakeUserService
TeamService *teamtest.FakeService
SAService *satests.MockServiceAccountService
}
func setupTestEnv(t *testing.T) *TestEnv {
t.Helper()
cfg := setting.NewCfg()
cfg.AppURL = AppURL
config := &fosite.Config{
AccessTokenLifespan: time.Hour,
TokenURL: TokenURL,
AccessTokenIssuer: AppURL,
IDTokenIssuer: AppURL,
ScopeStrategy: fosite.WildcardScopeStrategy,
}
fmgt := featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth)
env := &TestEnv{
Cfg: cfg,
AcStore: &actest.MockStore{},
OAuthStore: &oastest.MockStore{},
UserService: usertest.NewUserServiceFake(),
TeamService: teamtest.NewFakeService(),
SAService: &satests.MockServiceAccountService{},
}
env.S = &OAuth2ServiceImpl{
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
cfg: cfg,
accessControl: acimpl.ProvideAccessControl(cfg),
acService: acimpl.ProvideOSSService(cfg, env.AcStore, localcache.New(0, 0), fmgt),
memstore: storage.NewMemoryStore(),
sqlstore: env.OAuthStore,
logger: log.New("oauthserver.test"),
userService: env.UserService,
saService: env.SAService,
teamService: env.TeamService,
publicKey: &pk.PublicKey,
}
env.S.oauthProvider = newProvider(config, env.S, pk)
return env
}
func TestOAuth2ServiceImpl_SaveExternalService(t *testing.T) {
const serviceName = "my-ext-service"
sa1 := sa.ServiceAccountDTO{Id: 1, Name: serviceName, Login: serviceName, OrgId: oauthserver.TmpOrgID, IsDisabled: false, Role: "Viewer"}
sa1Profile := sa.ServiceAccountProfileDTO{Id: 1, Name: serviceName, Login: serviceName, OrgId: oauthserver.TmpOrgID, IsDisabled: false, Role: "Viewer"}
prevSaID := int64(3)
// Using a function to prevent modifying the same object in the tests
client1 := func() *oauthserver.ExternalService {
return &oauthserver.ExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: prevSaID,
SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
}
}
tests := []struct {
name string
init func(*TestEnv)
cmd *oauthserver.ExternalServiceRegistration
mockChecks func(*testing.T, *TestEnv)
wantErr bool
}{
{
name: "should create a new client without permissions",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFound(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
},
cmd: &oauthserver.ExternalServiceRegistration{
Name: serviceName,
Key: &oauthserver.KeyOption{Generate: true},
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalServiceByName", mock.Anything, mock.MatchedBy(func(name string) bool {
return name == serviceName
}))
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.ExternalService) bool {
return client.Name == serviceName && client.ClientID != "" && client.Secret != "" &&
len(client.GrantTypes) == 0 && len(client.PublicPem) > 0 && client.ServiceAccountID == 0 &&
len(client.ImpersonatePermissions) == 0
}))
},
},
{
name: "should create a service account",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFound(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
// Service account and permission creation
env.SAService.On("CreateServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(&sa1, nil)
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
},
cmd: &oauthserver.ExternalServiceRegistration{
Name: serviceName,
Key: &oauthserver.KeyOption{Generate: true},
Self: oauthserver.SelfCfg{
Enabled: true,
Permissions: []ac.Permission{{Action: "users:read", Scope: "users:*"}},
},
},
mockChecks: func(t *testing.T, env *TestEnv) {
// Check that the client has a service account and the correct grant type
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.ExternalService) bool {
return client.Name == serviceName &&
client.GrantTypes == "client_credentials" && client.ServiceAccountID == sa1.Id
}))
// Check that the service account is created in the correct org with the correct role
env.SAService.AssertCalled(t, "CreateServiceAccount", mock.Anything,
mock.MatchedBy(func(orgID int64) bool { return orgID == oauthserver.TmpOrgID }),
mock.MatchedBy(func(cmd *sa.CreateServiceAccountForm) bool {
return cmd.Name == serviceName && *cmd.Role == roletype.RoleViewer
}),
)
},
},
{
name: "should delete the service account",
init: func(env *TestEnv) {
// Existing client (with a service account hence a role)
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(client1(), nil)
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(&sa1Profile, nil)
// No permission anymore will trigger deletion of the service account and its role
env.SAService.On("DeleteServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(nil)
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
},
cmd: &oauthserver.ExternalServiceRegistration{
Name: serviceName,
Key: &oauthserver.KeyOption{Generate: true},
Self: oauthserver.SelfCfg{
Enabled: false,
},
},
mockChecks: func(t *testing.T, env *TestEnv) {
// Check that the service has no service account anymore
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.ExternalService) bool {
return client.Name == serviceName && client.ServiceAccountID == oauthserver.NoServiceAccountID
}))
// Check that the service account is retrieved with the correct ID
env.SAService.AssertCalled(t, "RetrieveServiceAccount", mock.Anything,
mock.MatchedBy(func(orgID int64) bool { return orgID == oauthserver.TmpOrgID }),
mock.MatchedBy(func(saID int64) bool { return saID == prevSaID }))
// Check that the service account is deleted in the correct org
env.SAService.AssertCalled(t, "DeleteServiceAccount", mock.Anything,
mock.MatchedBy(func(orgID int64) bool { return orgID == oauthserver.TmpOrgID }),
mock.MatchedBy(func(saID int64) bool { return saID == sa1.Id }))
// Check that the associated role is deleted
env.AcStore.AssertCalled(t, "DeleteExternalServiceRole", mock.Anything,
mock.MatchedBy(func(extSvcName string) bool { return extSvcName == serviceName }))
},
},
{
name: "should update the service account",
init: func(env *TestEnv) {
// Existing client (with a service account hence a role)
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(client1(), nil)
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(&sa1Profile, nil)
// Update the service account permissions
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
},
cmd: &oauthserver.ExternalServiceRegistration{
Name: serviceName,
Key: &oauthserver.KeyOption{Generate: true},
Self: oauthserver.SelfCfg{
Enabled: true,
Permissions: []ac.Permission{{Action: "dashboards:create", Scope: "folders:uid:general"}},
},
},
mockChecks: func(t *testing.T, env *TestEnv) {
// Ensure new permissions are in place
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
return cmd.ServiceAccountID == sa1.Id && cmd.ExternalServiceID == client1().Name &&
cmd.OrgID == int64(ac.GlobalOrgID) && len(cmd.Permissions) == 1 &&
cmd.Permissions[0] == ac.Permission{Action: "dashboards:create", Scope: "folders:uid:general"}
}))
},
},
{
name: "should allow jwt bearer grant and set default permissions",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFound(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
// The service account needs to be created with a permission to impersonate users
env.SAService.On("CreateServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(&sa1, nil)
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
},
cmd: &oauthserver.ExternalServiceRegistration{
Name: serviceName,
Key: &oauthserver.KeyOption{Generate: true},
Impersonation: oauthserver.ImpersonationCfg{
Enabled: true,
Groups: true,
Permissions: []ac.Permission{{Action: "dashboards:read", Scope: "dashboards:*"}},
},
},
mockChecks: func(t *testing.T, env *TestEnv) {
// Check that the external service impersonate permissions contains the default permissions required to populate the access token
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.ExternalService) bool {
impPerm := client.ImpersonatePermissions
return slices.Contains(impPerm, ac.Permission{Action: "dashboards:read", Scope: "dashboards:*"}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
}))
// Check that despite no credential_grants the service account still has a permission to impersonate users
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}
}))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
dto, err := env.S.SaveExternalService(context.Background(), tt.cmd)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
// Check that we generated client ID and secret
require.NotEmpty(t, dto.ID)
require.NotEmpty(t, dto.Secret)
// Check that we have generated keys and that we correctly return them
if tt.cmd.Key != nil && tt.cmd.Key.Generate {
require.NotNil(t, dto.KeyResult)
require.True(t, dto.KeyResult.Generated)
require.NotEmpty(t, dto.KeyResult.PublicPem)
require.NotEmpty(t, dto.KeyResult.PrivatePem)
}
// Check that we computed grant types and created or updated the service account
if tt.cmd.Self.Enabled {
require.NotNil(t, dto.GrantTypes)
require.Contains(t, dto.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should contain client_credentials")
} else {
require.NotContains(t, dto.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should not contain client_credentials")
}
// Check that we updated grant types
if tt.cmd.Impersonation.Enabled {
require.NotNil(t, dto.GrantTypes)
require.Contains(t, dto.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should contain JWT Bearer grant")
} else {
require.NotContains(t, dto.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should not contain JWT Bearer grant")
}
// Check that mocks were called as expected
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
env.AcStore.AssertExpectations(t)
// Additional checks performed
if tt.mockChecks != nil {
tt.mockChecks(t, env)
}
})
}
}
func TestOAuth2ServiceImpl_GetExternalService(t *testing.T) {
const serviceName = "my-ext-service"
dummyClient := func() *oauthserver.ExternalService {
return &oauthserver.ExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
}
}
cachedClient := &oauthserver.ExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
SignedInUser: &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {
"users:impersonate": {"users:*"},
},
},
},
}
testCases := []struct {
name string
init func(*TestEnv)
mockChecks func(*testing.T, *TestEnv)
wantPerm []ac.Permission
wantErr bool
}{
{
name: "should hit the cache",
init: func(env *TestEnv) {
env.S.cache.Set(serviceName, *cachedClient, time.Minute)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertNotCalled(t, "GetExternalService", mock.Anything, mock.Anything)
},
wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
},
{
name: "should return error when the client was not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFound(serviceName))
},
wantErr: true,
},
{
name: "should return error when the service account was not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ServiceAccountProfileDTO{}, sa.ErrServiceAccountNotFound)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveServiceAccount", mock.Anything, 1, 1)
},
wantErr: true,
},
{
name: "should return error when the service account has no permissions",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ServiceAccountProfileDTO{}, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("some error"))
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveServiceAccount", mock.Anything, 1, 1)
},
wantErr: true,
},
{
name: "should return correctly",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ServiceAccountProfileDTO{Id: 1}, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return([]ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}}, nil)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveServiceAccount", mock.Anything, int64(1), int64(1))
},
wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
},
{
name: "should return correctly when the client has no service account",
init: func(env *TestEnv) {
client := &oauthserver.ExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: oauthserver.NoServiceAccountID,
}
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(client, nil)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
},
wantPerm: []ac.Permission{},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
client, err := env.S.GetExternalService(context.Background(), serviceName)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.mockChecks != nil {
tt.mockChecks(t, env)
}
require.Equal(t, serviceName, client.Name)
require.ElementsMatch(t, client.SelfPermissions, tt.wantPerm)
assertArrayInMap(t, client.SignedInUser.Permissions[1], ac.GroupScopesByAction(tt.wantPerm))
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
})
}
}
func assertArrayInMap[K comparable, V string](t *testing.T, m1 map[K][]V, m2 map[K][]V) {
for k, v := range m1 {
require.Contains(t, m2, k)
require.ElementsMatch(t, v, m2[k])
}
}
func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) {
testCases := []struct {
name string
keyOption *oauthserver.KeyOption
expectedResult *oauthserver.KeyResult
wantErr bool
}{
{
name: "should return error when the key option is nil",
wantErr: true,
},
{
name: "should return error when the key option is empty",
keyOption: &oauthserver.KeyOption{},
wantErr: true,
},
{
name: "should return successfully when PublicPEM is specified",
keyOption: &oauthserver.KeyOption{
PublicPEM: base64.StdEncoding.EncodeToString([]byte(`-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`)),
},
wantErr: false,
expectedResult: &oauthserver.KeyResult{
PublicPem: `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`,
Generated: false,
PrivatePem: "",
URL: "",
},
},
}
env := setupTestEnv(t)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := env.S.handleKeyOptions(context.Background(), tc.keyOption)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.expectedResult, result)
})
}
t.Run("should generate an ECDSA key pair (default) when generate key option is specified", func(t *testing.T) {
result, err := env.S.handleKeyOptions(context.Background(), &oauthserver.KeyOption{Generate: true})
require.NoError(t, err)
require.NotNil(t, result.PrivatePem)
require.NotNil(t, result.PublicPem)
require.True(t, result.Generated)
})
t.Run("should generate an RSA key pair when generate key option is specified", func(t *testing.T) {
env.S.cfg.OAuth2ServerGeneratedKeyTypeForClient = "RSA"
result, err := env.S.handleKeyOptions(context.Background(), &oauthserver.KeyOption{Generate: true})
require.NoError(t, err)
require.NotNil(t, result.PrivatePem)
require.NotNil(t, result.PublicPem)
require.True(t, result.Generated)
})
}

View File

@ -0,0 +1,16 @@
package oasimpl
import (
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/token/jwt"
)
func NewAuthSession() *oauth2.JWTSession {
sess := &oauth2.JWTSession{
JWTClaims: new(jwt.JWTClaims),
JWTHeader: new(jwt.Headers),
}
// Our tokens will follow the RFC9068
sess.JWTHeader.Add("typ", "at+jwt")
return sess
}

View File

@ -0,0 +1,351 @@
package oasimpl
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer)
func (s *OAuth2ServiceImpl) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {
// This context will be passed to all methods.
ctx := req.Context()
// Create an empty session object which will be passed to the request handlers
oauthSession := NewAuthSession()
// This will create an access request object and iterate through the registered TokenEndpointHandlers to validate the request.
accessRequest, err := s.oauthProvider.NewAccessRequest(ctx, req, oauthSession)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
client, err := s.GetExternalService(ctx, accessRequest.GetClient().GetID())
if err != nil || client == nil {
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
})
return
}
oauthSession.JWTClaims.Add("client_id", client.ClientID)
errClientCred := s.handleClientCredentials(ctx, accessRequest, oauthSession, client)
if errClientCred != nil {
s.writeAccessError(ctx, rw, accessRequest, errClientCred)
return
}
errJWTBearer := s.handleJWTBearer(ctx, accessRequest, oauthSession, client)
if errJWTBearer != nil {
s.writeAccessError(ctx, rw, accessRequest, errJWTBearer)
return
}
// All tokens we generate in this service should target Grafana's API.
accessRequest.GrantAudience(s.cfg.AppURL)
// Prepare response, fosite handlers will populate the token.
response, err := s.oauthProvider.NewAccessResponse(ctx, accessRequest)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
s.oauthProvider.WriteAccessResponse(ctx, rw, accessRequest, response)
}
// writeAccessError logs the error then uses fosite to write the error back to the user.
func (s *OAuth2ServiceImpl) writeAccessError(ctx context.Context, rw http.ResponseWriter, accessRequest fosite.AccessRequester, err error) {
var fositeErr *fosite.RFC6749Error
if errors.As(err, &fositeErr) {
s.logger.Error("description", fositeErr.DescriptionField, "hint", fositeErr.HintField, "error", fositeErr.ErrorField)
} else {
s.logger.Error("error", err)
}
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, err)
}
// splitOAuthScopes sort scopes that are generic (profile, email, groups, entitlements) from scopes
// that are RBAC actions (used to further restrict the entitlements embedded in the access_token)
func splitOAuthScopes(requestedScopes fosite.Arguments) (map[string]bool, map[string]bool) {
actionsFilter := map[string]bool{}
claimsFilter := map[string]bool{}
for _, scope := range requestedScopes {
switch scope {
case "profile", "email", "groups", "entitlements":
claimsFilter[scope] = true
default:
actionsFilter[scope] = true
}
}
return actionsFilter, claimsFilter
}
// handleJWTBearer populates the "impersonation" access_token generated by fosite to match the rfc9068 specifications (entitlements, groups).
// It ensures that the user can be impersonated, that the generated token audiences only contain Grafana's AppURL (and token endpoint)
// and that entitlements solely contain the user's permissions that the client is allowed to have.
func (s *OAuth2ServiceImpl) handleJWTBearer(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) {
return nil
}
userID, err := utils.ParseUserIDFromSubject(oauthSession.Subject)
if err != nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
// Check audiences list only contains the AppURL and the token endpoint
for _, aud := range accessRequest.GetGrantedAudience() {
if aud != fmt.Sprintf("%voauth2/token", s.cfg.AppURL) && aud != s.cfg.AppURL {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to target this Audience.",
HintField: "The audience must be the AppURL or the token endpoint.",
ErrorField: "invalid_request",
CodeField: http.StatusForbidden,
}
}
}
// If the client was not allowed to impersonate the user we would not have reached this point given allowed scopes would have been empty
// But just in case we check again
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
hasAccess, errAccess := s.accessControl.Evaluate(ctx, client.SignedInUser, ev)
if errAccess != nil || !hasAccess {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to impersonate subject.",
ErrorField: "restricted_access",
CodeField: http.StatusForbidden,
}
}
// Populate claims' suject from the session subject
oauthSession.JWTClaims.Subject = oauthSession.Subject
// Get the user
query := user.GetUserByIDQuery{ID: userID}
dbUser, err := s.userService.GetByID(ctx, &query)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
return &fosite.RFC6749Error{
DescriptionField: "The request subject could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
oauthSession.Username = dbUser.Login
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
teams := []*team.TeamDTO{}
// Fetch teams if the groups scope is requested or if we need to populate it in the entitlements
if claimsFilter["groups"] ||
(claimsFilter["entitlements"] && (len(actionsFilter) == 0 || actionsFilter["teams:read"])) {
var errGetTeams error
teams, errGetTeams = s.teamService.GetTeamsByUser(ctx, &team.GetTeamsByUserQuery{
OrgID: oauthserver.TmpOrgID,
UserID: dbUser.ID,
// Fetch teams without restriction on permissions
SignedInUser: &user.SignedInUser{
OrgID: oauthserver.TmpOrgID,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
ac.ActionTeamsRead: {ac.ScopeTeamsAll},
},
},
},
})
if errGetTeams != nil {
return &fosite.RFC6749Error{
DescriptionField: "The teams scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
}
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", dbUser.Name)
oauthSession.JWTClaims.Add("login", dbUser.Login)
oauthSession.JWTClaims.Add("updated_at", dbUser.Updated.Unix())
}
if claimsFilter["email"] {
oauthSession.JWTClaims.Add("email", dbUser.Email)
}
if claimsFilter["groups"] {
teamNames := make([]string, 0, len(teams))
for _, team := range teams {
teamNames = append(teamNames, team.Name)
}
oauthSession.JWTClaims.Add("groups", teamNames)
}
if claimsFilter["entitlements"] {
// Get the user permissions (apply the actions filter)
permissions, errGetPermission := s.filteredUserPermissions(ctx, userID, actionsFilter)
if errGetPermission != nil {
return errGetPermission
}
// Compute the impersonated permissions (apply the actions filter, replace the scope self with the user id)
impPerms := s.filteredImpersonatePermissions(client.ImpersonatePermissions, userID, teams, actionsFilter)
// Intersect the permissions with the client permissions
intesect := ac.Intersect(permissions, impPerms)
oauthSession.JWTClaims.Add("entitlements", intesect)
}
return nil
}
// filteredUserPermissions gets the user permissions and applies the actions filter
func (s *OAuth2ServiceImpl) filteredUserPermissions(ctx context.Context, userID int64, actionsFilter map[string]bool) ([]ac.Permission, error) {
permissions, err := s.acService.SearchUserPermissions(ctx, oauthserver.TmpOrgID, ac.SearchOptions{UserID: userID})
if err != nil {
return nil, &fosite.RFC6749Error{
DescriptionField: "The permissions scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range permissions {
if actionsFilter[permissions[i].Action] {
filtered = append(filtered, permissions[i])
}
}
permissions = filtered
}
return permissions, nil
}
// filteredImpersonatePermissions computes the impersonated permissions.
// It applies the actions filter and replaces the "self RBAC scopes" (~ scope templates) by the correct user id/team id.
func (*OAuth2ServiceImpl) filteredImpersonatePermissions(impersonatePermissions []ac.Permission, userID int64, teams []*team.TeamDTO, actionsFilter map[string]bool) []ac.Permission {
// Compute the impersonated permissions
impPerms := impersonatePermissions
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range impPerms {
if actionsFilter[impPerms[i].Action] {
filtered = append(filtered, impPerms[i])
}
}
impPerms = filtered
}
// Replace the scope self with the user id
correctScopes := []ac.Permission{}
for i := range impPerms {
switch impPerms[i].Scope {
case oauthserver.ScopeGlobalUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("global.users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeTeamsSelf:
for t := range teams {
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("teams", "id", strconv.FormatInt(teams[t].ID, 10)),
})
}
default:
correctScopes = append(correctScopes, impPerms[i])
}
continue
}
return correctScopes
}
// handleClientCredentials populates the client's access_token generated by fosite to match the rfc9068 specifications (entitlements, groups)
func (s *OAuth2ServiceImpl) handleClientCredentials(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne("client_credentials") {
return nil
}
// Set the subject to the service account associated to the client
oauthSession.JWTClaims.Subject = fmt.Sprintf("user:id:%d", client.ServiceAccountID)
sa := client.SignedInUser
if sa == nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the service account of the client",
ErrorField: "not_found",
CodeField: http.StatusNotFound,
}
}
oauthSession.Username = sa.Login
// For client credentials, scopes are not marked as granted by fosite but the request would have been rejected
// already if the client was not allowed to request them
for _, scope := range accessRequest.GetRequestedScopes() {
accessRequest.GrantScope(scope)
}
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", sa.Name)
oauthSession.JWTClaims.Add("login", sa.Login)
}
if claimsFilter["email"] {
s.logger.Debug("Service accounts have no emails")
}
if claimsFilter["groups"] {
s.logger.Debug("Service accounts have no groups")
}
if claimsFilter["entitlements"] {
s.logger.Debug("Processing client entitlements")
if sa.Permissions != nil && sa.Permissions[oauthserver.TmpOrgID] != nil {
perms := sa.Permissions[oauthserver.TmpOrgID]
if len(actionsFilter) > 0 {
filtered := map[string][]string{}
for action := range actionsFilter {
if _, ok := perms[action]; ok {
filtered[action] = perms[action]
}
}
perms = filtered
}
oauthSession.JWTClaims.Add("entitlements", perms)
} else {
s.logger.Debug("Client has no permissions")
}
}
return nil
}

View File

@ -0,0 +1,747 @@
package oasimpl
import (
"context"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/ory/fosite"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/maps"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/grafana/grafana/pkg/models/roletype"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
func TestOAuth2ServiceImpl_handleClientCredentials(t *testing.T) {
client1 := &oauthserver.ExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeClientCredentials),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
"dashboards:read": {"dashboards:*", "folders:*"},
"dashboards:write": {"dashboards:uid:1"},
},
},
},
}
tests := []struct {
name string
scopes []string
client *oauthserver.ExternalService
expectedClaims map[string]interface{}
wantErr bool
}{
{
name: "no claim without client_credentials grant type",
client: &oauthserver.ExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{},
},
},
{
name: "no claims without scopes",
client: client1,
},
{
name: "profile claims",
client: client1,
scopes: []string{"profile"},
expectedClaims: map[string]interface{}{"name": "Test App", "login": "testapp"},
},
{
name: "email claims should be empty",
client: client1,
scopes: []string{"email"},
},
{
name: "groups claims should be empty",
client: client1,
scopes: []string{"groups"},
},
{
name: "entitlements claims",
client: client1,
scopes: []string{"entitlements"},
expectedClaims: map[string]interface{}{"entitlements": map[string][]string{
"dashboards:read": {"dashboards:*", "folders:*"},
"dashboards:write": {"dashboards:uid:1"},
}},
},
{
name: "scoped entitlements claims",
client: client1,
scopes: []string{"entitlements", "dashboards:write"},
expectedClaims: map[string]interface{}{"entitlements": map[string][]string{
"dashboards:write": {"dashboards:uid:1"},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupTestEnv(t)
session := &fosite.DefaultSession{}
requester := fosite.NewAccessRequest(session)
requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ","))
requester.RequestedScope = fosite.Arguments(tt.scopes)
sessionData := NewAuthSession()
err := env.S.handleClientCredentials(ctx, requester, sessionData, tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.expectedClaims == nil {
require.Empty(t, sessionData.JWTClaims.Extra)
return
}
require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims))
for claimsKey, claimsValue := range tt.expectedClaims {
switch expected := claimsValue.(type) {
case []string:
require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
case map[string][]string:
actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string)
require.True(t, ok, "expected map[string][]string")
require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual))
for expKey, expValue := range expected {
require.ElementsMatch(t, expValue, actual[expKey])
}
default:
require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
}
}
})
}
}
func TestOAuth2ServiceImpl_handleJWTBearer(t *testing.T) {
now := time.Now()
client1 := &oauthserver.ExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: oauthserver.TmpOrgID,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
"users:impersonate": {"users:*"},
},
},
},
}
user56 := &user.User{
ID: 56,
Email: "user56@example.org",
Login: "user56",
Name: "User 56",
Updated: now,
}
teams := []*team.TeamDTO{
{ID: 1, Name: "Team 1", OrgID: 1},
{ID: 2, Name: "Team 2", OrgID: 1},
}
client1WithPerm := func(perms []ac.Permission) *oauthserver.ExternalService {
client := *client1
client.ImpersonatePermissions = perms
return &client
}
tests := []struct {
name string
initEnv func(*TestEnv)
scopes []string
client *oauthserver.ExternalService
subject string
expectedClaims map[string]interface{}
wantErr bool
}{
{
name: "no claim without jwtbearer grant type",
client: &oauthserver.ExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeClientCredentials),
ServiceAccountID: 2,
},
},
{
name: "err invalid subject",
client: client1,
subject: "invalid_subject",
wantErr: true,
},
{
name: "err client is not allowed to impersonate",
client: &oauthserver.ExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
},
},
subject: "user:id:56",
wantErr: true,
},
{
name: "err subject not found",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedError = user.ErrUserNotFound
},
client: client1,
subject: "user:id:56",
wantErr: true,
},
{
name: "no claim without scope",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
},
{
name: "profile claims",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
scopes: []string{"profile"},
expectedClaims: map[string]interface{}{
"name": "User 56",
"login": "user56",
"updated_at": now.Unix(),
},
},
{
name: "email claim",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
scopes: []string{"email"},
expectedClaims: map[string]interface{}{
"email": "user56@example.org",
},
},
{
name: "groups claim",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
},
client: client1,
subject: "user:id:56",
scopes: []string{"groups"},
expectedClaims: map[string]interface{}{
"groups": []string{"Team 1", "Team 2"},
},
},
{
name: "no entitlement without permission intersection",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {{Action: "dashboards:read", Scope: "dashboards:uid:1"}},
}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]interface{}{
"entitlements": map[string][]string{},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements contains only the intersection of permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "dashboards:read", Scope: "dashboards:uid:1"},
{Action: "datasources:read", Scope: "datasources:uid:1"},
},
}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]interface{}{
"entitlements": map[string][]string{
"datasources:read": {"datasources:uid:1"},
},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements have correctly translated users:self permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "users:read", Scope: "global.users:id:*"},
{Action: "users.permissions:read", Scope: "users:id:*"},
}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "users:read", Scope: "global.users:self"},
{Action: "users.permissions:read", Scope: "users:self"},
}),
subject: "user:id:56",
expectedClaims: map[string]interface{}{
"entitlements": map[string][]string{
"users:read": {"global.users:id:56"},
"users.permissions:read": {"users:id:56"},
},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements have correctly translated teams:self permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {{Action: "teams:read", Scope: "teams:*"}}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "teams:read", Scope: "teams:self"},
}),
subject: "user:id:56",
expectedClaims: map[string]interface{}{
"entitlements": map[string][]string{"teams:read": {"teams:id:1", "teams:id:2"}},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements are correctly filtered based on scopes",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "users:read", Scope: "global.users:id:*"},
{Action: "datasources:read", Scope: "datasources:uid:1"},
}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "users:read", Scope: "global.users:*"},
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]interface{}{
"entitlements": map[string][]string{"users:read": {"global.users:id:*"}},
},
scopes: []string{"entitlements", "users:read"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupTestEnv(t)
session := &fosite.DefaultSession{}
requester := fosite.NewAccessRequest(session)
requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ","))
requester.RequestedScope = fosite.Arguments(tt.scopes)
requester.GrantedScope = fosite.Arguments(tt.scopes)
sessionData := NewAuthSession()
sessionData.Subject = tt.subject
if tt.initEnv != nil {
tt.initEnv(env)
}
err := env.S.handleJWTBearer(ctx, requester, sessionData, tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.expectedClaims == nil {
require.Empty(t, sessionData.JWTClaims.Extra)
return
}
require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims))
for claimsKey, claimsValue := range tt.expectedClaims {
switch expected := claimsValue.(type) {
case []string:
require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
case map[string][]string:
actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string)
require.True(t, ok, "expected map[string][]string")
require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual))
for expKey, expValue := range expected {
require.ElementsMatch(t, expValue, actual[expKey])
}
default:
require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
}
}
env.AcStore.AssertExpectations(t)
})
}
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type claims struct {
jwt.Claims
ClientID string `json:"client_id"`
Groups []string `json:"groups"`
Email string `json:"email"`
Name string `json:"name"`
Login string `json:"login"`
Scopes []string `json:"scope"`
Entitlements map[string][]string `json:"entitlements"`
}
func TestOAuth2ServiceImpl_HandleTokenRequest(t *testing.T) {
tests := []struct {
name string
tweakTestClient func(*oauthserver.ExternalService)
reqParams url.Values
wantCode int
wantScope []string
wantClaims *claims
}{
{
name: "should allow client credentials grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
wantCode: http.StatusOK,
wantScope: []string{"profile", "email", "groups", "entitlements"},
wantClaims: &claims{
Claims: jwt.Claims{
Subject: "user:id:2", // From client1.ServiceAccountID
Issuer: AppURL, // From env.S.Config.Issuer
Audience: jwt.Audience{AppURL},
},
ClientID: "CLIENT1ID",
Name: "client-1",
Login: "client-1",
Entitlements: map[string][]string{
"users:impersonate": {"users:*"},
},
},
},
{
name: "should allow jwt-bearer grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
wantCode: http.StatusOK,
wantScope: []string{"profile", "email", "groups", "entitlements"},
wantClaims: &claims{
Claims: jwt.Claims{
Subject: "user:id:56", // To match the assertion
Issuer: AppURL, // From env.S.Config.Issuer
Audience: jwt.Audience{TokenURL, AppURL},
},
ClientID: "CLIENT1ID",
Email: "user56@example.org",
Name: "User 56",
Login: "user56",
Groups: []string{"Team 1", "Team 2"},
Entitlements: map[string][]string{
"dashboards:read": {"folders:uid:UID1"},
"folders:read": {"folders:uid:UID1"},
"users:read": {"global.users:id:56"},
},
},
},
{
name: "should deny jwt-bearer grant with wrong audience",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, "invalid audience"),
},
"scope": {"profile email groups entitlements"},
},
wantCode: http.StatusForbidden,
},
{
name: "should deny jwt-bearer grant for clients without the grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
tweakTestClient: func(es *oauthserver.ExternalService) {
es.GrantTypes = string(fosite.GrantTypeClientCredentials)
},
wantCode: http.StatusBadRequest,
},
{
name: "should deny client_credentials grant for clients without the grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
tweakTestClient: func(es *oauthserver.ExternalService) {
es.GrantTypes = string(fosite.GrantTypeJWTBearer)
},
wantCode: http.StatusBadRequest,
},
{
name: "should deny client_credentials grant with wrong secret",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"WRONG_SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
tweakTestClient: func(es *oauthserver.ExternalService) {
es.GrantTypes = string(fosite.GrantTypeClientCredentials)
},
wantCode: http.StatusUnauthorized,
},
{
name: "should deny jwt-bearer grant with wrong secret",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"WRONG_SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
tweakTestClient: func(es *oauthserver.ExternalService) {
es.GrantTypes = string(fosite.GrantTypeJWTBearer)
},
wantCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
setupHandleTokenRequestEnv(t, env, tt.tweakTestClient)
req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader(tt.reqParams.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
env.S.HandleTokenRequest(resp, req)
require.Equal(t, tt.wantCode, resp.Code)
if tt.wantCode != http.StatusOK {
return
}
body := resp.Body.Bytes()
require.NotEmpty(t, body)
var tokenResp tokenResponse
require.NoError(t, json.Unmarshal(body, &tokenResp))
// Check token response
require.NotEmpty(t, tokenResp.Scope)
require.ElementsMatch(t, tt.wantScope, strings.Split(tokenResp.Scope, " "))
require.Positive(t, tokenResp.ExpiresIn)
require.Equal(t, "bearer", tokenResp.TokenType)
require.NotEmpty(t, tokenResp.AccessToken)
// Check access token
parsedToken, err := jwt.ParseSigned(tokenResp.AccessToken)
require.NoError(t, err)
require.Len(t, parsedToken.Headers, 1)
typeHeader := parsedToken.Headers[0].ExtraHeaders["typ"]
require.Equal(t, "at+jwt", strings.ToLower(typeHeader.(string)))
require.Equal(t, "RS256", parsedToken.Headers[0].Algorithm)
// Check access token claims
var claims claims
require.NoError(t, parsedToken.Claims(pk.Public(), &claims))
// Check times and remove them
require.Positive(t, claims.IssuedAt.Time())
require.Positive(t, claims.Expiry.Time())
claims.IssuedAt = jwt.NewNumericDate(time.Time{})
claims.Expiry = jwt.NewNumericDate(time.Time{})
// Check the ID and remove it
require.NotEmpty(t, claims.ID)
claims.ID = ""
// Compare the rest
require.Equal(t, tt.wantClaims, &claims)
})
}
}
func genAssertion(t *testing.T, signKey *rsa.PrivateKey, clientID, sub string, audience ...string) string {
key := jose.SigningKey{Algorithm: jose.RS256, Key: signKey}
assertion := jwt.Claims{
ID: uuid.New().String(),
Issuer: clientID,
Subject: sub,
Audience: audience,
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
var signerOpts = jose.SignerOptions{}
signerOpts.WithType("JWT")
rsaSigner, errSigner := jose.NewSigner(key, &signerOpts)
require.NoError(t, errSigner)
builder := jwt.Signed(rsaSigner)
rawJWT, errSign := builder.Claims(assertion).CompactSerialize()
require.NoError(t, errSign)
return rawJWT
}
// setupHandleTokenRequestEnv creates a client and a user and sets all Mocks call for the handleTokenRequest test cases
func setupHandleTokenRequestEnv(t *testing.T, env *TestEnv, opt func(*oauthserver.ExternalService)) {
now := time.Now()
hashedSecret, err := bcrypt.GenerateFromPassword([]byte("CLIENT1SECRET"), bcrypt.DefaultCost)
require.NoError(t, err)
client1 := &oauthserver.ExternalService{
Name: "client-1",
ClientID: "CLIENT1ID",
Secret: string(hashedSecret),
GrantTypes: string(fosite.GrantTypeClientCredentials + "," + fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
ImpersonatePermissions: []ac.Permission{
{Action: "users:read", Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: "users.permissions:read", Scope: oauthserver.ScopeUsersSelf},
{Action: "teams:read", Scope: oauthserver.ScopeTeamsSelf},
{Action: "folders:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
SelfPermissions: []ac.Permission{
{Action: "users:impersonate", Scope: "users:*"},
},
Audiences: AppURL,
}
// Apply any option the test case might need
if opt != nil {
opt(client1)
}
sa1 := &serviceaccounts.ServiceAccountProfileDTO{
Id: client1.ServiceAccountID,
Name: client1.Name,
Login: client1.Name,
OrgId: oauthserver.TmpOrgID,
IsDisabled: false,
Created: now,
Updated: now,
Role: "Viewer",
}
user56 := &user.User{
ID: 56,
Email: "user56@example.org",
Login: "user56",
Name: "User 56",
Updated: now,
}
user56Permissions := []ac.Permission{
{Action: "users:read", Scope: "global.users:id:56"},
{Action: "folders:read", Scope: "folders:uid:UID1"},
{Action: "dashboards:read", Scope: "folders:uid:UID1"},
{Action: "datasources:read", Scope: "datasources:uid:DS_UID2"}, // This one should be ignored when impersonating
}
user56Teams := []*team.TeamDTO{
{ID: 1, Name: "Team 1", OrgID: 1},
{ID: 2, Name: "Team 2", OrgID: 1},
}
// To retrieve the Client, its publicKey and its permissions
env.OAuthStore.On("GetExternalService", mock.Anything, client1.ClientID).Return(client1, nil)
env.OAuthStore.On("GetExternalServicePublicKey", mock.Anything, client1.ClientID).Return(&jose.JSONWebKey{Key: Client1Key.Public(), Algorithm: "RS256"}, nil)
env.SAService.On("RetrieveServiceAccount", mock.Anything, oauthserver.TmpOrgID, client1.ServiceAccountID).Return(sa1, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(client1.SelfPermissions, nil)
// To retrieve the user to impersonate, its permissions and its teams
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
user56.ID: user56Permissions}, nil)
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
user56.ID: {"Viewer"}}, nil)
env.TeamService.ExpectedTeamsByUser = user56Teams
env.UserService.ExpectedUser = user56
}

View File

@ -0,0 +1,29 @@
package oastest
import (
"context"
"net/http"
"github.com/grafana/grafana/pkg/services/oauthserver"
"gopkg.in/square/go-jose.v2"
)
type FakeService struct {
ExpectedClient *oauthserver.ExternalService
ExpectedKey *jose.JSONWebKey
ExpectedErr error
}
var _ oauthserver.OAuth2Server = &FakeService{}
func (s *FakeService) SaveExternalService(ctx context.Context, cmd *oauthserver.ExternalServiceRegistration) (*oauthserver.ExternalServiceDTO, error) {
return s.ExpectedClient.ToDTO(), s.ExpectedErr
}
func (s *FakeService) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
return s.ExpectedClient, s.ExpectedErr
}
func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {}
func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {}

View File

@ -0,0 +1,138 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package oastest
import (
context "context"
mock "github.com/stretchr/testify/mock"
jose "gopkg.in/square/go-jose.v2"
oauthserver "github.com/grafana/grafana/pkg/services/oauthserver"
)
// MockStore is an autogenerated mock type for the Store type
type MockStore struct {
mock.Mock
}
// GetExternalService provides a mock function with given fields: ctx, id
func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
ret := _m.Called(ctx, id)
var r0 *oauthserver.ExternalService
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.ExternalService, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.ExternalService); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauthserver.ExternalService)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalServiceByName provides a mock function with given fields: ctx, name
func (_m *MockStore) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.ExternalService, error) {
ret := _m.Called(ctx, name)
var r0 *oauthserver.ExternalService
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.ExternalService, error)); ok {
return rf(ctx, name)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.ExternalService); ok {
r0 = rf(ctx, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauthserver.ExternalService)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalServicePublicKey provides a mock function with given fields: ctx, clientID
func (_m *MockStore) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) {
ret := _m.Called(ctx, clientID)
var r0 *jose.JSONWebKey
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*jose.JSONWebKey, error)); ok {
return rf(ctx, clientID)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *jose.JSONWebKey); ok {
r0 = rf(ctx, clientID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*jose.JSONWebKey)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, clientID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RegisterExternalService provides a mock function with given fields: ctx, client
func (_m *MockStore) RegisterExternalService(ctx context.Context, client *oauthserver.ExternalService) error {
ret := _m.Called(ctx, client)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.ExternalService) error); ok {
r0 = rf(ctx, client)
} else {
r0 = ret.Error(0)
}
return r0
}
// SaveExternalService provides a mock function with given fields: ctx, client
func (_m *MockStore) SaveExternalService(ctx context.Context, client *oauthserver.ExternalService) error {
ret := _m.Called(ctx, client)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.ExternalService) error); ok {
r0 = rf(ctx, client)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockStore interface {
mock.TestingT
Cleanup(func())
}
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockStore(t mockConstructorTestingTNewMockStore) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,219 @@
package store
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"errors"
"gopkg.in/square/go-jose.v2"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
"github.com/grafana/grafana/pkg/util/errutil"
)
type store struct {
db db.DB
}
func NewStore(db db.DB) oauthserver.Store {
return &store{db: db}
}
func createImpersonatePermissions(sess *db.Session, client *oauthserver.ExternalService) error {
if len(client.ImpersonatePermissions) == 0 {
return nil
}
insertPermQuery := make([]interface{}, 1, len(client.ImpersonatePermissions)*3+1)
insertPermStmt := `INSERT INTO oauth_impersonate_permission (client_id, action, scope) VALUES `
for _, perm := range client.ImpersonatePermissions {
insertPermStmt += "(?, ?, ?),"
insertPermQuery = append(insertPermQuery, client.ClientID, perm.Action, perm.Scope)
}
insertPermQuery[0] = insertPermStmt[:len(insertPermStmt)-1]
_, err := sess.Exec(insertPermQuery...)
return err
}
func registerExternalService(sess *db.Session, client *oauthserver.ExternalService) error {
insertQuery := []interface{}{
`INSERT INTO oauth_client (name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
client.Name,
client.ClientID,
client.Secret,
client.GrantTypes,
client.Audiences,
client.ServiceAccountID,
client.PublicPem,
client.RedirectURI,
}
if _, err := sess.Exec(insertQuery...); err != nil {
return err
}
return createImpersonatePermissions(sess, client)
}
func (s *store) RegisterExternalService(ctx context.Context, client *oauthserver.ExternalService) error {
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
return registerExternalService(sess, client)
})
}
func recreateImpersonatePermissions(sess *db.Session, client *oauthserver.ExternalService, prevClientID string) error {
deletePermQuery := `DELETE FROM oauth_impersonate_permission WHERE client_id = ?`
if _, errDelPerm := sess.Exec(deletePermQuery, prevClientID); errDelPerm != nil {
return errDelPerm
}
if len(client.ImpersonatePermissions) == 0 {
return nil
}
return createImpersonatePermissions(sess, client)
}
func updateExternalService(sess *db.Session, client *oauthserver.ExternalService, prevClientID string) error {
updateQuery := []interface{}{
`UPDATE oauth_client SET client_id = ?, secret = ?, grant_types = ?, audiences = ?, service_account_id = ?, public_pem = ?, redirect_uri = ? WHERE name = ?`,
client.ClientID,
client.Secret,
client.GrantTypes,
client.Audiences,
client.ServiceAccountID,
client.PublicPem,
client.RedirectURI,
client.Name,
}
if _, err := sess.Exec(updateQuery...); err != nil {
return err
}
return recreateImpersonatePermissions(sess, client, prevClientID)
}
func (s *store) SaveExternalService(ctx context.Context, client *oauthserver.ExternalService) error {
if client.Name == "" {
return oauthserver.ErrClientRequiredName
}
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
previous, errFetchExtSvc := getExternalServiceByName(sess, client.Name)
if errFetchExtSvc != nil {
var srcError errutil.Error
if errors.As(errFetchExtSvc, &srcError) {
if srcError.MessageID != oauthserver.ErrClientNotFoundMessageID {
return errFetchExtSvc
}
}
}
if previous == nil {
return registerExternalService(sess, client)
}
return updateExternalService(sess, client, previous.ClientID)
})
}
func (s *store) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
res := &oauthserver.ExternalService{}
if id == "" {
return nil, oauthserver.ErrClientRequiredID
}
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
getClientQuery := `SELECT
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri
FROM oauth_client
WHERE client_id = ?`
found, err := sess.SQL(getClientQuery, id).Get(res)
if err != nil {
return err
}
if !found {
return oauthserver.ErrClientNotFound(id)
}
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?`
return sess.SQL(impersonatePermQuery, id).Find(&res.ImpersonatePermissions)
})
return res, err
}
// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check
// signature of jwt assertion in authorization grants.
func (s *store) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) {
res := &oauthserver.ExternalService{}
if clientID == "" {
return nil, oauthserver.ErrClientRequiredID
}
if err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
getKeyQuery := `SELECT public_pem FROM oauth_client WHERE client_id = ?`
found, err := sess.SQL(getKeyQuery, clientID).Get(res)
if err != nil {
return err
}
if !found {
return oauthserver.ErrClientNotFound(clientID)
}
return nil
}); err != nil {
return nil, err
}
key, errParseKey := utils.ParsePublicKeyPem(res.PublicPem)
if errParseKey != nil {
return nil, errParseKey
}
var alg string
switch key.(type) {
case *rsa.PublicKey:
alg = oauthserver.RS256
case *ecdsa.PublicKey:
alg = oauthserver.ES256
}
return &jose.JSONWebKey{
Algorithm: alg,
Key: key,
}, nil
}
func (s *store) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.ExternalService, error) {
res := &oauthserver.ExternalService{}
if name == "" {
return nil, oauthserver.ErrClientRequiredName
}
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
var errGetByName error
res, errGetByName = getExternalServiceByName(sess, name)
return errGetByName
})
return res, err
}
func getExternalServiceByName(sess *db.Session, name string) (*oauthserver.ExternalService, error) {
res := &oauthserver.ExternalService{}
getClientQuery := `SELECT
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri
FROM oauth_client
WHERE name = ?`
found, err := sess.SQL(getClientQuery, name).Get(res)
if err != nil {
return nil, err
}
if !found {
return nil, oauthserver.ErrClientNotFound(name)
}
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?`
errPerm := sess.SQL(impersonatePermQuery, res.ClientID).Find(&res.ImpersonatePermissions)
return res, errPerm
}

View File

@ -0,0 +1,376 @@
package store
import (
"context"
"testing"
"github.com/go-jose/go-jose/v3"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthserver"
)
func TestStore_RegisterAndGetClient(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
tests := []struct {
name string
client oauthserver.ExternalService
wantErr bool
}{
{
name: "register and get",
client: oauthserver.ExternalService{
Name: "The Worst App Ever",
ClientID: "ANonRandomClientID",
Secret: "ICouldKeepSecrets",
GrantTypes: "clients_credentials",
PublicPem: []byte(`------BEGIN FAKE PUBLIC KEY-----
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4uLi4gSXQgSXMgSnVz
dCBBIFJlZ3VsYXIgQmFzZTY0IEVuY29kZWQgU3RyaW5nLi4uCg==
------END FAKE PUBLIC KEY-----`),
ServiceAccountID: 2,
SelfPermissions: nil,
ImpersonatePermissions: nil,
RedirectURI: "/whereto",
},
wantErr: false,
},
{
name: "register with impersonate permissions and get",
client: oauthserver.ExternalService{
Name: "The Best App Ever",
ClientID: "AnAlmostRandomClientID",
Secret: "ICannotKeepSecrets",
GrantTypes: "clients_credentials",
PublicPem: []byte(`test`),
ServiceAccountID: 2,
SelfPermissions: nil,
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:create", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
{Action: "dashboards:write", Scope: "folders:*"},
{Action: "dashboards:write", Scope: "dashboards:*"},
},
RedirectURI: "/whereto",
},
wantErr: false,
},
{
name: "register with audiences and get",
client: oauthserver.ExternalService{
Name: "The Most Normal App Ever",
ClientID: "AnAlmostRandomClientIDAgain",
Secret: "ICanKeepSecretsEventually",
GrantTypes: "clients_credentials",
PublicPem: []byte(`test`),
ServiceAccountID: 2,
SelfPermissions: nil,
Audiences: "https://oauth.test/,https://sub.oauth.test/",
RedirectURI: "/whereto",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := s.RegisterExternalService(ctx, &tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
// Compare results
compareClientToStored(t, s, &tt.client)
})
}
}
func TestStore_SaveExternalService(t *testing.T) {
client1 := oauthserver.ExternalService{
Name: "my-external-service",
ClientID: "ClientID",
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte("test"),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
client1WithPerm := client1
client1WithPerm.ImpersonatePermissions = []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
}
client1WithNewSecrets := client1
client1WithNewSecrets.ClientID = "NewClientID"
client1WithNewSecrets.Secret = "NewSecret"
client1WithNewSecrets.PublicPem = []byte("newtest")
client1WithAud := client1
client1WithAud.Audiences = "https://oauth.test/,https://sub.oauth.test/"
tests := []struct {
name string
runs []oauthserver.ExternalService
wantErr bool
}{
{
name: "error no name",
runs: []oauthserver.ExternalService{{}},
wantErr: true,
},
{
name: "simple register",
runs: []oauthserver.ExternalService{client1},
wantErr: false,
},
{
name: "no update",
runs: []oauthserver.ExternalService{client1, client1},
wantErr: false,
},
{
name: "add permissions",
runs: []oauthserver.ExternalService{client1, client1WithPerm},
wantErr: false,
},
{
name: "remove permissions",
runs: []oauthserver.ExternalService{client1WithPerm, client1},
wantErr: false,
},
{
name: "update id and secrets",
runs: []oauthserver.ExternalService{client1, client1WithNewSecrets},
wantErr: false,
},
{
name: "update audience",
runs: []oauthserver.ExternalService{client1, client1WithAud},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
for i := range tt.runs {
err := s.SaveExternalService(context.Background(), &tt.runs[i])
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
compareClientToStored(t, s, &tt.runs[i])
}
})
}
}
func TestStore_GetExternalServiceByName(t *testing.T) {
client1 := oauthserver.ExternalService{
Name: "my-external-service",
ClientID: "ClientID",
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte("test"),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
client2 := oauthserver.ExternalService{
Name: "my-external-service-2",
ClientID: "ClientID2",
Secret: "Secret2",
GrantTypes: "client_credentials,urn:ietf:params:grant-type:jwt-bearer",
PublicPem: []byte("test2"),
ServiceAccountID: 3,
Audiences: "https://oauth.test/,https://sub.oauth.test/",
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
RedirectURI: "/whereto",
}
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), &client1))
require.NoError(t, s.SaveExternalService(context.Background(), &client2))
tests := []struct {
name string
search string
want *oauthserver.ExternalService
wantErr bool
}{
{
name: "no name provided",
search: "",
want: nil,
wantErr: true,
},
{
name: "not found",
search: "unknown-external-service",
want: nil,
wantErr: true,
},
{
name: "search client 1 by name",
search: "my-external-service",
want: &client1,
wantErr: false,
},
{
name: "search client 2 by name",
search: "my-external-service-2",
want: &client2,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stored, err := s.GetExternalServiceByName(context.Background(), tt.search)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
compareClients(t, stored, tt.want)
})
}
}
func TestStore_GetExternalServicePublicKey(t *testing.T) {
clientID := "ClientID"
createClient := func(clientID string, publicPem string) *oauthserver.ExternalService {
return &oauthserver.ExternalService{
Name: "my-external-service",
ClientID: clientID,
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte(publicPem),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
}
testCases := []struct {
name string
client *oauthserver.ExternalService
clientID string
want *jose.JSONWebKey
wantKeyType string
wantErr bool
}{
{
name: "should return an error when clientID is empty",
clientID: "",
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return an error when the client was not found",
clientID: "random",
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return an error when PublicPem is not valid",
clientID: clientID,
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return the JSON Web Key ES256",
clientID: clientID,
client: createClient(clientID, `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`),
wantKeyType: oauthserver.ES256,
wantErr: false,
},
{
name: "should return the JSON Web Key RS256",
clientID: clientID,
client: createClient(clientID, `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxkly/cHvsxd6EcShGUlFAB5lIMlIbGRocCVWbIM26f6pnGr+gCNv
s365DQdQ/jUjF8bSEQM+EtjGlv2Y7Jm7dQROpPzX/1M+53Us/Gl138UtAEgL5ZKe
SKN5J/f9Nx4wkgb99v2Bt0nz6xv+kSJwgR0o8zi8shDR5n7a5mTdlQe2NOixzWlT
vnpp6Tm+IE+XyXXcrCr01I9Rf+dKuYOPSJ1K3PDgFmmGvsLcjRCCK9EftfY0keU+
IP+sh8ewNxc6KcaLBXm3Tadb1c/HyuMi6FyYw7s9m8tyAvI1CMBAcXqLIEaRgNrc
vuO8AU0bVoUmYMKhozkcCYHudkeS08hEjQIDAQAB
-----END RSA PUBLIC KEY-----`),
wantKeyType: oauthserver.RS256,
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), tc.client))
webKey, err := s.GetExternalServicePublicKey(context.Background(), tc.clientID)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantKeyType, webKey.Algorithm)
})
}
}
func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.ExternalService) {
ctx := context.Background()
stored, err := s.GetExternalService(ctx, wanted.ClientID)
require.NoError(t, err)
require.NotNil(t, stored)
compareClients(t, stored, wanted)
}
func compareClients(t *testing.T, stored *oauthserver.ExternalService, wanted *oauthserver.ExternalService) {
// Reset ID so we can compare
require.NotZero(t, stored.ID)
stored.ID = 0
// Compare permissions separately
wantedPerms := wanted.ImpersonatePermissions
storedPerms := stored.ImpersonatePermissions
wanted.ImpersonatePermissions = nil
stored.ImpersonatePermissions = nil
require.EqualValues(t, *wanted, *stored)
require.ElementsMatch(t, wantedPerms, storedPerms)
}

View File

@ -0,0 +1,35 @@
package utils
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/authn"
)
// ParseUserIDFromSubject parses the user ID from format "user:id:<id>".
func ParseUserIDFromSubject(subject string) (int64, error) {
trimmed := strings.TrimPrefix(subject, fmt.Sprintf("%s:id:", authn.NamespaceUser))
return strconv.ParseInt(trimmed, 10, 64)
}
// ParsePublicKeyPem parses the public key from the PEM encoded public key.
func ParsePublicKeyPem(publicPem []byte) (interface{}, error) {
block, _ := pem.Decode(publicPem)
if block == nil {
return nil, errors.New("could not decode PEM block")
}
switch block.Type {
case "PUBLIC KEY":
return x509.ParsePKIXPublicKey(block.Bytes)
case "RSA PUBLIC KEY":
return x509.ParsePKCS1PublicKey(block.Bytes)
default:
return nil, fmt.Errorf("unknown key type %q", block.Type)
}
}

View File

@ -0,0 +1,82 @@
package utils
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParsePublicKeyPem(t *testing.T) {
testCases := []struct {
name string
publicKeyPem string
wantErr bool
}{
{
name: "should return error when the public key pem is empty",
publicKeyPem: "",
wantErr: true,
},
{
name: "should return error when the public key pem is invalid",
publicKeyPem: `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxP72NEnQF3o3eFFMtFqyloW9oLhTydxXS2dA2NolMvXewO77
UvJw54wkOdrJrJO2BIw+XBrrb+13+koRUnwa2DNsh+SWG0PEe/31mt0zJrCmNM37
iJYIu3KZR2aRlierVY5gyrIniBIZ9blQspI6SRY9xmo6Wdh0VCRnsCV5sMlaqerI
snLpYOjGtMmL0rFuW2jKrAzpbq7L99IDgPbiH7tluaQkGIxoc29S4wjwg0NgQONT
GkfJEeXQIkxOHNM5WGb8mvjX4U0jMdXvC4WUWcS+KpcIV7ee8uEs2xDz++N4HYAS
ty37sY8wwW22QUW9I7XlSC4rsC88Ft5ar8yLsQIDAQABAoIBAAQ1yTv+mFmKGYGj
JiskFZVBNDdpPRQvNvfj8+c2iU08ozc3HEyuZQKT1InefsknCoCwIRyNkDrPBc2F
8/cR8y5r8e25EUqxoPM/7xXxVIinBZRTEyU9BKCB71vu6Z1eiWs9jNzEIDNopKCj
ZmG8nY2Gkckp58eYCEtskEE72c0RBPg8ZTBdc1cLqbNVUjkLvR5e98ruDz6b+wyH
FnztZ0k48zM047Ior69OwFRBg+S7d6cgMMmcq4X2pg3xgQMs0Se/4+pmvBf9JPSB
kl3qpVAkzM1IFdrmpFtBzeaqYNj3uU6Bm7NxEiqjAoeDxO231ziSdzIPtXIy5eRl
9WMZCqkCgYEA1ZOaT77aa54zgjAwjNB2Poo3yoUtYJz+yNCR0CPM4MzCas3PR4XI
WUXo+RNofWvRJF88aAVX7+J0UTnRr25rN12NDbo3aZhX2YNDGBe3hgB/FOAI5UAh
9SaU070PFeGzqlu/xWdx5GFk/kiNUNLX/X4xgUGPTiwY4LQeI9lffzkCgYEA7CA7
VHaNPGVsaNKMJVjrZeYOxNBsrH99IEgaP76DC+EVR2JYVzrNxmN6ZlRxD4CRTcyd
oquTFoFFw26gJIJAYF8MtusOD3PArnpdCRSoELezYdtVhS0yx8TSHGVC9MWSSt7O
IdjzEFpA99HPkYFjXUiWXjfCTK7ofI0RXC6a+DkCgYEAoQb8nYuEGwfYRhwXPtQd
kuGbVvI6WFGGN9opVgjn+8Xl/6jU01QmzkhLcyAS9B1KPmYfoT4GIzNWB7fURLS3
2bKLGwJ/rPnTooe5Gn0nPb06E38mtdI4yCEirNIqgZD+aT9rw2ZPFKXqA16oTXvq
pZFzucS4S3Qr/Z9P6i+GNOECgYBkvPuS9WEcO0kdD3arGFyVhKkYXrN+hIWlmB1a
xLS0BLtHUTXPQU85LIez0KLLslZLkthN5lVCbLSOxEueR9OfSe3qvC2ref7icWHv
1dg+CaGGRkUeJEJd6CKb6re+Jexb9OKMnjpU56yADgs4ULNLwQQl/jPu81BMkwKt
CVUkQQKBgFvbuUmYtP3aqV/Kt036Q6aB6Xwg29u2XFTe4BgW7c55teebtVmGA/zc
GMwRsF4rWCcScmHGcSKlW9L6S6OxmkYjDDRhimKyHgoiQ9tawWag2XCeOlyJ+hkc
/qwwKxScuFIi2xwT+aAmR70Xk11qXTft+DaEcHdxOOZD8gA0Gxr3
-----END RSA PRIVATE KEY-----`,
wantErr: true,
},
{
name: "should parse the public key if it is in PKCS1 format",
publicKeyPem: `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAy06MeS06Ea7zGKfOM8kosxuUBMNhrWKWMvW4Jq1IXG+lyTfann2+
kI1rKeWAQ9YbxNzLynahoKN47EQ6mqM1Yj5v9iKWtSvCMKHWBuqrG5ksaEQaAVsA
PDg8aOQrI1MSW9Hoc1CummcWX+HKNPVwIzG3sCboENFzEG8GrJgoNHZgmyOYEMMD
2WCdfY0I9Dm0/uuNMAcyMuVhRhOtT3j91zCXvDju2+M2EejApMkV9r7FqGmNH5Hw
8u43nWXnWc4UYXEItE8nPxuqsZia2mdB5MSIdKu8a7ytFcQ+tiK6vempnxHZytEL
6NDX8DLydHbEsLUn6hc76ODVkr/wRiuYdQIDAQAB
-----END RSA PUBLIC KEY-----`,
wantErr: false,
},
{
name: "should parse the public key if it is in PKIX/X.509 format",
publicKeyPem: `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`,
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := ParsePublicKeyPem([]byte(tc.publicKeyPem))
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -0,0 +1,52 @@
package tests
import (
"context"
"github.com/stretchr/testify/mock"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
var _ serviceaccounts.Service = &MockServiceAccountService{}
type MockServiceAccountService struct {
mock.Mock
}
// AddServiceAccountToken implements serviceaccounts.Service
func (s *MockServiceAccountService) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) (*apikey.APIKey, error) {
mockedArgs := s.Called(ctx, serviceAccountID, cmd)
return mockedArgs.Get(0).(*apikey.APIKey), mockedArgs.Error(1)
}
// CreateServiceAccount implements serviceaccounts.Service
func (s *MockServiceAccountService) CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
mockedArgs := s.Called(ctx, orgID, saForm)
return mockedArgs.Get(0).(*serviceaccounts.ServiceAccountDTO), mockedArgs.Error(1)
}
// DeleteServiceAccount implements serviceaccounts.Service
func (s *MockServiceAccountService) DeleteServiceAccount(ctx context.Context, orgID int64, serviceAccountID int64) error {
mockedArgs := s.Called(ctx, orgID, serviceAccountID)
return mockedArgs.Error(0)
}
// RetrieveServiceAccount implements serviceaccounts.Service
func (s *MockServiceAccountService) RetrieveServiceAccount(ctx context.Context, orgID int64, serviceAccountID int64) (*serviceaccounts.ServiceAccountProfileDTO, error) {
mockedArgs := s.Called(ctx, orgID, serviceAccountID)
return mockedArgs.Get(0).(*serviceaccounts.ServiceAccountProfileDTO), mockedArgs.Error(1)
}
// RetrieveServiceAccountIdByName implements serviceaccounts.Service
func (s *MockServiceAccountService) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
mockedArgs := s.Called(ctx, orgID, name)
return mockedArgs.Get(0).(int64), mockedArgs.Error(1)
}
// UpdateServiceAccount implements serviceaccounts.Service
func (s *MockServiceAccountService) UpdateServiceAccount(ctx context.Context, orgID int64, serviceAccountID int64, saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
mockedArgs := s.Called(ctx, orgID, serviceAccountID)
return mockedArgs.Get(0).(*serviceaccounts.ServiceAccountProfileDTO), mockedArgs.Error(1)
}

View File

@ -2,13 +2,13 @@ package signingkeysimpl
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"github.com/go-jose/go-jose/v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/signingkeys"
)
@ -18,13 +18,13 @@ const (
var _ signingkeys.Service = new(Service)
func ProvideEmbeddedSigningKeysService(features *featuremgmt.FeatureManager) (*Service, error) {
func ProvideEmbeddedSigningKeysService() (*Service, error) {
s := &Service{
log: log.New("auth.key_service"),
keys: map[string]crypto.Signer{},
}
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.log.Error("Error generating private key", "err", err)
return nil, signingkeys.ErrKeyGenerationFailed.Errorf("Error generating private key: %v", err)

View File

@ -2,7 +2,7 @@ package signingkeysimpl
import (
"crypto"
"crypto/rsa"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
@ -15,64 +15,17 @@ import (
)
const (
privateKeyPem = `-----BEGIN RSA PRIVATE KEY-----
MIIJJgIBAAKCAgBixs4SiJylE8NwaR/AN2gr/XWgTfFqwg3m7rm018MSmMZxph77
lZ96n/UqaAtEL9wCHjU0/76dhMtn6yGXmS9s3zTwOfuy5Hv4ai0PjEoRrxdtbKT8
u0F0N7HJupBeUBZ86ELhlTw+OgOqxbWv/V6uN81UG/tadaR00k9yyfcT0noCE+3a
5l4OT7q2ILJL5nvyKgwcZJxGfoBwkGX42BZuIxZ4ANx3Mz/uQrkRMg+5bDDYgvlV
OsEhoDHmq4DsRODeVyCN0If0HL0fPIUoVv8C87igVnTq3ScxikypndK1uytKLTJP
ZsenbyfLyvR/jBAu2WZVYS0JSYAxN+4wJH8H1dLotYXpn/YSPBAsR/EHi4kpu5v+
OBSGhMl21ZSeNNFUqX/YnRjYEYGgQuhYRnfzFaROUh3bWq25WC7bxTWwqtnA1FX2
Vqr0tgNly0hCr+KP/kkUe7xiGzjBIC+A89b7y70l3m3j/kTj3TXVSzcwn7aGOO8X
OILw/x7vF08LYC26wLBOk2uPcraR5aKNy6KPhy8rMYLv8u4jNzGP8Y6ISMYyBv5N
tJ5BLHn80hbx/Vo5zADJ8WeMIUmtxLRD6oedX8za5Jpa3b71cx55zFhYiVThKeS2
by9PKi2xurd5AYWVtJBr2azTMFY2FdGVbB02/21twepQXrRl17ucfaxapQIDAQAB
AoICAEO2QQHXgHpxR+LBTbC4ysKNJ5tSkxI6IMmUEN31opYXAMJbvJV+hirLiIcf
d8mwfUM+bf786jCVHdMJDqgbrLUXdfTP6slBc/Jg5q7n3sasnoS2m4tc2ovOuiOt
rtXYVPIfTenSIdAOeQESM3CHYeZP/oOQAwiJ6Mjkeu4XoTaHbHgMLVuH3CY3ZakA
VPlO8NybEl5MYgy5H1cKxbyGdSnfB8IP5RIZodO1DaTKCplznzBs6HsSod5pMIwO
OXy94uDIHVrZ/rjLEqJdHHMA4COn64KOgeuW2w1M3yzPMei+e/iHbxubO3Z97mv3
nw/odheHlG0nBnZ9WlFjI/cArctWjqSfs7mEX6aV+Ity0+msMWWgrjg6l0y0rlqa
odYt2KIzyAcsFiZCUUgsmNRzB8kVycNwjDFpW24ZvwWtakvH/uZ/lK5jloXOF3Id
TTf4T+h6vtHjEMzfOKmrp2fycfgjavBEX/VMASHooB5H2lzB9poSC9k1V+HAnirq
s5PSehX2QnHvuFCG47iFN1xX747hESph1plzO17xMsKQnWPDQw8ega3fkW3MMQdx
wFOriHYZBk2o7pQ6aSErMMqlVM9PS2HXHTOV4ejAEYsFtnGqfZB3RSt3+4DIhyjo
+YS4At/nfWMyxTo5R/9EkuTCzZTfPVEq/7E8gPsK8c3GC/xpAoIBAQC51/DUpDtv
PsU02PO7m/+u5s1OJLE/PybUI0fNTfL+DAdzOfFlrJkyfhYNBOx2BrhYZsL4bZb/
nvAj7L8nvUDTeNdhejrR3SFom8U9cxM28ZCBNNn9rCnkSNPdn5FsUr8QqOEJwWZG
6KXJ/c019LV+0ncn7fN5GYnPhlVgQCmAnSxudwRmH0uqXhV/p1F+veTe4TL/CHXf
ZrcW01pYlNtRB7D4bQ9YMPxgKaNNl8IcpZdKCocxImbTJeSn6nz7ZeMCVeUP/BuP
a2aBWe76xvxubm+NZbzcsj3b8tAYngAaL/yh9+uX3yqVA2Y5DR+0m5qgYehTlqET
jf1cXA9oA/JfAoIBAQCIEKYswfIRQUXoUx905KWT4leVaWRI37nQVjrVYG6ElN26
mMMIKlN/BghteZB3Wrh9p4ZkHlLMMpXj6vRZRhpgjfiOxeiIkjDdQYQao/q7ZStr
H0G37lOiboxmMWpLI2XOrNAlTYmDCVVTSjoF0zxvMzIyvRV488X6tI8LAIf3QjDj
+6IrJH1RF1AGwLSeD07JWq4F3epg6BwEXlMePCwUr8cUYAIrPlGWT/ywP9ZKX5Wt
mNEZEgaWAohvdXGbkG4cQuIT2fvd2HvYDjbr9CvQDV5tHIE36jUrlbzVRHYxp0QQ
XbPTTN9On6fSueYoFy47CtXJOHrbZ+r74CU0yHl7AoIBACwQYl7YzerTlEiyhB/g
niAnQ1ia5JfdbmRwNQ8dw1avHXkZrP3xjaVmNe5CU5qsfzseqm3i9iGH2uJ5uN1A
R0Wc6lyHcbje2JQIEx090rl9T0kDcghusMQa7Hko438uo3TcxfbdL1XyxZR+JBD+
A6adWnlSNx9oib9113pp3C1NlwJeH+Hi27r6cdiBoJYPilu6Q7AqnmAo55J27H4C
VXoB+9j7at77Rmu6k6jLKdBHBvccRe/Fe2HnIy8ZLycgglHEcfp3SUWZLoXPABXf
5mx8rOB21e/yJy6mhObBV77dz+XLdcXduSf51VwDm5fkKSaL8F0ZYvnS+dbTUSfV
f7sCggEAOQPw/jRPARf++TlLpyngkDV6Seud0EOfk0Nu59a+uOPAfd5ha1yBHGsk
wOr9tGXZhR3b3LwwKczQrm7X8UjE6MzU6M7Zf9DylORNPPSVrkzYgszYNwCxHxF/
15rBVbcBhDc6CUeSZcxVas9hvOslGdu0HzrIcqSDw2hBwHR6hQvBfOcGr1ldAcvp
BstdZBY6B3nuDhtNiUn544K7BaJlPk3h+BG7Fu/INFpUIm69lvCywcmVZRH+nIF3
Nm1aK7u7yC/mmDbxqaZ7Tq+2J+1rJoVTmhkltI55tUfLlvpXJLtYdBsvrU07DbEt
G8o2PXppLuh9aRI3uRS0jNMCBDo1XQKCAQAa2CsPi/ey9JzgUkBJvVusv7fF8hYB
4Nno4PXiRezIGbT9WitZU5lQhfw0g9XokyQqKwSS6iEtuSRE92P6XLB0jswQQ/Jc
5yWX9DqjKKE4dtpS4/VfkdfE6daIqtFCfE3gybnah/FWPAtYY4iC1207lZQjAp91
OFOV2sfpk4ZIwnSJBvY0O5Brt/nbHkFUzxJRFgERD7zRrFOU9mZdEUfR9jvj4xlI
NcKeaYuoa4nWwuLEEzNTQqcS8ccOrpGTZQP2ffpyZdY42q4N8UggTdAcwOtQ6a6L
D3U+YcnG00aa3FnNN5EjOnY4FeIUJwpqzB8mDc0ztHdwOoJhDETWroDq
-----END RSA PRIVATE KEY-----`
privateKeyPem = `-----BEGIN PRIVATE KEY-----
MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCAv8mcYDAugBtzfGYP9
ielIkb6/Ys51o7KjHxtANhPesw==
-----END PRIVATE KEY-----`
)
func getPrivateKey(t *testing.T) *rsa.PrivateKey {
func getPrivateKey(t *testing.T) *ecdsa.PrivateKey {
pemBlock, _ := pem.Decode([]byte(privateKeyPem))
privateKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
privateKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
require.NoError(t, err)
return privateKey
return privateKey.(*ecdsa.PrivateKey)
}
func setupTestService(t *testing.T) *Service {
@ -271,6 +224,15 @@ func TestEmbeddedKeyService_AddPrivateKey(t *testing.T) {
}
}
func TestProvideEmbeddedSigningKeysService(t *testing.T) {
s, err := ProvideEmbeddedSigningKeysService()
require.NoError(t, err)
require.NotNil(t, s)
// Verify that ProvideEmbeddedSigningKeysService generates an ECDSA private key by default
require.IsType(t, &ecdsa.PrivateKey{}, s.GetServerPrivateKey())
}
type dummyPrivateKey struct {
}

View File

@ -1,7 +1,9 @@
package migrations
import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
@ -89,6 +91,11 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
AddExternalAlertmanagerToDatasourceMigration(mg)
addFolderMigrations(mg)
if mg.Cfg != nil && mg.Cfg.IsFeatureToggleEnabled != nil {
if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagExternalServiceAuth) {
oauthserver.AddMigration(mg)
}
}
}
func addStarMigrations(mg *Migrator) {

View File

@ -0,0 +1,52 @@
package oauthserver
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func AddMigration(mg *migrator.Migrator) {
impersonatePermissionsTable := migrator.Table{
Name: "oauth_impersonate_permission",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "action", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "scope", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
},
Indices: []*migrator.Index{
{Cols: []string{"client_id", "action", "scope"}, Type: migrator.UniqueIndex},
},
}
clientTable := migrator.Table{
Name: "oauth_client",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "name", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "secret", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "grant_types", Type: migrator.DB_Text, Nullable: true},
{Name: "audiences", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
{Name: "service_account_id", Type: migrator.DB_BigInt, Nullable: true},
{Name: "public_pem", Type: migrator.DB_Text, Nullable: true},
{Name: "redirect_uri", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
},
Indices: []*migrator.Index{
{Cols: []string{"client_id"}, Type: migrator.UniqueIndex},
{Cols: []string{"client_id", "service_account_id"}, Type: migrator.UniqueIndex},
{Cols: []string{"name"}, Type: migrator.UniqueIndex},
},
}
// Impersonate Permission
mg.AddMigration("create impersonate permissions table", migrator.NewAddTableMigration(impersonatePermissionsTable))
//------- indexes ------------------
mg.AddMigration("add unique index client_id action scope", migrator.NewAddIndexMigration(impersonatePermissionsTable, impersonatePermissionsTable.Indices[0]))
// Client
mg.AddMigration("create client table", migrator.NewAddTableMigration(clientTable))
//------- indexes ------------------
mg.AddMigration("add unique index client_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[0]))
mg.AddMigration("add unique index client_id service_account_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[1]))
mg.AddMigration("add unique index name", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[2]))
}

View File

@ -509,6 +509,13 @@ type Cfg struct {
OktaAuthEnabled bool
OktaSkipOrgRoleSync bool
// OAuth2 Server
OAuth2ServerEnabled bool
// OAuth2Server supports the two recommended key types from the RFC https://www.rfc-editor.org/rfc/rfc7518#section-3.1: RS256 and ES256
OAuth2ServerGeneratedKeyTypeForClient string
OAuth2ServerAccessTokenLifespan time.Duration
// Access Control
RBACEnabled bool
RBACPermissionCache bool
@ -1045,6 +1052,9 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
if err := readAuthSettings(iniFile, cfg); err != nil {
return err
}
readOAuth2ServerSettings(cfg)
readAccessControlSettings(iniFile, cfg)
if err := cfg.readRenderingSettings(iniFile); err != nil {
return err
@ -1595,6 +1605,13 @@ func readAccessControlSettings(iniFile *ini.File, cfg *Cfg) {
cfg.RBACResetBasicRoles = rbac.Key("reset_basic_roles").MustBool(false)
}
func readOAuth2ServerSettings(cfg *Cfg) {
oauth2Srv := cfg.SectionWithEnvOverrides("oauth2_server")
cfg.OAuth2ServerEnabled = oauth2Srv.Key("enabled").MustBool(false)
cfg.OAuth2ServerGeneratedKeyTypeForClient = strings.ToUpper(oauth2Srv.Key("generated_key_type_for_client").In("ECDSA", []string{"RSA", "ECDSA"}))
cfg.OAuth2ServerAccessTokenLifespan = oauth2Srv.Key("access_token_lifespan").MustDuration(time.Minute * 3)
}
func readUserSettings(iniFile *ini.File, cfg *Cfg) error {
users := iniFile.Section("users")
AllowUserSignUp = users.Key("allow_sign_up").MustBool(true)