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:
co-authored by
Mihály Gyöngyösi
parent
73681a251e
commit
edf1775d49
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user