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

* Moving POC files from #64283 to a new branch

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

* Adding missing permission definition

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

* Force the service instantiation while client isn't merged

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

* Merge conf with main

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

* Leave go-sqlite3 version unchanged

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

* tidy

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

* User SearchUserPermissions instead of SearchUsersPermissions

* Replace DummyKeyService with signingkeys.Service

* Use user🆔<id> as subject

* Fix introspection endpoint issue

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

* Regenerate toggles_gen.go
* Fix basic.go

* Add GetExternalService tests

* Add GetPublicKeyScopes tests

* Add GetScopesOnUser tests

* Add GetScopes tests

* Add ParsePublicKeyPem tests

* Add database test for GetByName

* re-add comments

* client tests added

* Add GetExternalServicePublicKey tests

* Add other test case to GetExternalServicePublicKey

* client_credentials grant test

* Add test to jwtbearer grant

* Test Comments

* Add handleKeyOptions tests

* Add RSA key generation test

* Add ECDSA by default to EmbeddedSigningKeysService

* Clean up org id scope and audiences

* Add audiences to the DB

* Fix check on Audience

* Fix double import

* Add AC Store mock and align oauthserver tests

* Fix test after rebase

* Adding missing store function to mock

* Fix double import

* Add CODEOWNER

* Fix some linting errors

* errors don't need type assertion

* Typo codeowners

* use mockery for oauthserver store

* Add feature toggle check

* Fix db tests to handle the feature flag

* Adding call to DeleteExternalServiceRole

* Fix flaky test

* Re-organize routes comments and plan futur work

* Add client_id check to Extended JWT client

* Clean up

* Fix

* Remove background service registry instantiation of the OAuth server

* Comment cleanup

* Remove unused client function

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

* Remove oauth2_server related configs from defaults.ini

* Add audiences to DTO

* Fix flaky test

* Remove registration endpoint and demo scripts. Document code

* Rename packages

* Remove the OAuthService vs OAuthServer confusion

* fix incorrect import ext_jwt_test

* Comments and order

* Comment basic auth

* Remove unecessary todo

* Clean api

* Moving ParsePublicKeyPem to utils

* re ordering functions in service.go

* Fix comment

* comment on the redirect uri

* Add RBAC actions, not only scopes

* Fix tests

* re-import featuremgmt in migrations

* Fix wire

* Fix scopes in test

* Fix flaky test

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

* Remove unecessary check from intersection code

* Allow env overrides on settings

* remove the term app name

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

* LogID remove ExternalService ref

* Use Name instead of ExternalServiceName

* Imports order

* Inline

* Using ExternalService and ExternalServiceDTO

* Remove xorm tags

* comment

* Rename client files

* client -> external service

* comments

* Move test to correct package

* slimmer test

* cachedUser -> cachedExternalService

* Fix aggregate store test

* PluginAuthSession -> AuthSession

* Revert the nil cehcks

* Remove unecessary extra

* Removing custom session

* fix typo in test

* Use constants for tests

* Simplify HandleToken tests

* Refactor the HandleTokenRequest test

* test message

* Review test

* Prevent flacky test on client as well

* go imports

* Revert changes from 526e48ad45

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

* AuthN: change the External Service registration form

* Gen default permissions

* Change demo script registration form

* Remove unecessary comment

* Nit.

* Reduce cyclomatic complexity

* Remove demo_scripts

* Handle case with no service account

* Comments

* Group key gen

* Nit.

* Check the SaveExternalService test

* Rename cachedUser to cachedClient in test

* One more test case to database test

* Comments

* Remove last org scope

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

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

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

Remove comment

* Update pkg/setting/setting.go

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

---------

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE
2023-05-25 15:38:30 +02:00
committed by GitHub
co-authored by Mihály Gyöngyösi
parent 73681a251e
commit edf1775d49
38 changed files with 5190 additions and 113 deletions
@@ -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)
}
+501
View File
@@ -0,0 +1,501 @@
package oasimpl
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/storage"
"github.com/ory/fosite/token/jwt"
"golang.org/x/crypto/bcrypt"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/models/roletype"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/api"
"github.com/grafana/grafana/pkg/services/oauthserver/store"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
)
const (
cacheExpirationTime = 5 * time.Minute
cacheCleanupInterval = 5 * time.Minute
)
type OAuth2ServiceImpl struct {
cache *localcache.CacheService
memstore *storage.MemoryStore
cfg *setting.Cfg
sqlstore oauthserver.Store
oauthProvider fosite.OAuth2Provider
logger log.Logger
accessControl ac.AccessControl
acService ac.Service
saService serviceaccounts.Service
userService user.Service
teamService team.Service
publicKey interface{}
}
func ProvideService(router routing.RouteRegister, db db.DB, cfg *setting.Cfg, skv kvstore.SecretsKVStore,
svcAccSvc serviceaccounts.Service, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service,
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) {
if !fmgmt.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
return nil, nil
}
config := &fosite.Config{
AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan,
TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL),
AccessTokenIssuer: cfg.AppURL,
IDTokenIssuer: cfg.AppURL,
ScopeStrategy: fosite.WildcardScopeStrategy,
}
privateKey := keySvc.GetServerPrivateKey()
var publicKey interface{}
switch k := privateKey.(type) {
case *rsa.PrivateKey:
publicKey = &k.PublicKey
case *ecdsa.PrivateKey:
publicKey = &k.PublicKey
default:
return nil, fmt.Errorf("unknown private key type %T", k)
}
s := &OAuth2ServiceImpl{
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
cfg: cfg,
accessControl: accessControl,
acService: acSvc,
memstore: storage.NewMemoryStore(),
sqlstore: store.NewStore(db),
logger: log.New("oauthserver"),
userService: userSvc,
saService: svcAccSvc,
teamService: teamSvc,
publicKey: publicKey,
}
api := api.NewAPI(router, s)
api.RegisterAPIEndpoints()
s.oauthProvider = newProvider(config, s, privateKey)
return s, nil
}
func newProvider(config *fosite.Config, storage interface{}, key interface{}) fosite.OAuth2Provider {
keyGetter := func(context.Context) (interface{}, error) {
return key, nil
}
return compose.Compose(
config,
storage,
&compose.CommonStrategy{
CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config),
Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter},
},
compose.OAuth2ClientCredentialsGrantFactory,
compose.RFC7523AssertionGrantFactory,
compose.OAuth2TokenIntrospectionFactory,
compose.OAuth2TokenRevocationFactory,
)
}
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account.
// For performance reason, the service uses caching.
func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
entry, ok := s.cache.Get(id)
if ok {
client, ok := entry.(oauthserver.ExternalService)
if ok {
s.logger.Debug("GetExternalService: cache hit", "id", id)
return &client, nil
}
}
client, err := s.sqlstore.GetExternalService(ctx, id)
if err != nil {
return nil, err
}
// Handle the case where the external service has no service account
if client.ServiceAccountID == oauthserver.NoServiceAccountID {
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "id", id, "name", client.Name)
// Create a signed in user with no role and no permissions
client.SignedInUser = &user.SignedInUser{
UserID: oauthserver.NoServiceAccountID,
OrgID: oauthserver.TmpOrgID,
Name: client.Name,
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
}
s.cache.Set(id, *client, cacheExpirationTime)
return client, nil
}
// Retrieve self permissions and generate a signed in user
s.logger.Debug("GetExternalService: fetch permissions", "client id", id)
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID)
if err != nil {
s.logger.Error("GetExternalService: error fetching service account", "id", id, "error", err)
return nil, err
}
client.SignedInUser = &user.SignedInUser{
UserID: sa.Id,
OrgID: oauthserver.TmpOrgID,
OrgRole: org.RoleType(sa.Role), // Need this to compute the permissions in OSS
Login: sa.Login,
Name: sa.Name,
Permissions: map[int64]map[string][]string{},
}
client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{})
if err != nil {
s.logger.Error("GetExternalService: error fetching permissions", "id", id, "error", err)
return nil, err
}
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions)
s.cache.Set(id, *client, cacheExpirationTime)
return client, nil
}
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions.
// Database consistency is not guaranteed, consider changing this in the future.
func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *oauthserver.ExternalServiceRegistration) (*oauthserver.ExternalServiceDTO, error) {
if registration == nil {
s.logger.Warn("RegisterExternalService called without registration")
return nil, nil
}
s.logger.Info("Registering external service", "external service name", registration.Name)
// Check if the client already exists in store
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, registration.Name)
if errFetchExtSvc != nil {
var srcError errutil.Error
if errors.As(errFetchExtSvc, &srcError) {
if srcError.MessageID != oauthserver.ErrClientNotFoundMessageID {
s.logger.Error("Error fetching service", "external service", registration.Name, "error", errFetchExtSvc)
return nil, errFetchExtSvc
}
}
}
// Otherwise, create a new client
if client == nil {
s.logger.Debug("External service does not yet exist", "external service name", registration.Name)
client = &oauthserver.ExternalService{
Name: registration.Name,
ServiceAccountID: oauthserver.NoServiceAccountID,
Audiences: s.cfg.AppURL,
}
}
// Parse registration form to compute required permissions for the client
client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration)
if registration.RedirectURI != nil {
client.RedirectURI = *registration.RedirectURI
}
var errGenCred error
client.ClientID, client.Secret, errGenCred = s.genCredentials()
if errGenCred != nil {
s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred)
return nil, errGenCred
}
s.logger.Debug("Save service account")
saID, errSaveServiceAccount := s.saveServiceAccount(ctx, client.Name, client.ServiceAccountID, client.SelfPermissions)
if errSaveServiceAccount != nil {
return nil, errSaveServiceAccount
}
client.ServiceAccountID = saID
grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled)
client.GrantTypes = strings.Join(grantTypes, ",")
// Handle key options
s.logger.Debug("Handle key options")
keys, err := s.handleKeyOptions(ctx, registration.Key)
if err != nil {
s.logger.Error("Error handling key options", "client", client.LogID(), "error", err)
return nil, err
}
if keys != nil {
client.PublicPem = []byte(keys.PublicPem)
}
dto := client.ToDTO()
dto.KeyResult = keys
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err)
return nil, err
}
client.Secret = string(hashedSecret)
err = s.sqlstore.SaveExternalService(ctx, client)
if err != nil {
s.logger.Error("Error saving external service", "client", client.LogID(), "error", err)
return nil, err
}
s.logger.Debug("Registered", "client", client.LogID())
return dto, nil
}
// randString generates a a cryptographically secure random string of n bytes
func (s *OAuth2ServiceImpl) randString(n int) (string, error) {
res := make([]byte, n)
if _, err := rand.Read(res); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(res), nil
}
func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) {
id, err := s.randString(20)
if err != nil {
return "", "", err
}
// client_secret must be at least 32 bytes long
secret, err := s.randString(32)
if err != nil {
return "", "", err
}
return id, secret, err
}
func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string {
grantTypes := []string{}
if selfAccessEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
}
if impersonationEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer))
}
return grantTypes
}
func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *oauthserver.KeyOption) (*oauthserver.KeyResult, error) {
if keyOption == nil {
return nil, fmt.Errorf("keyOption is nil")
}
var publicPem, privatePem string
if keyOption.Generate {
switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient {
case "RSA":
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}))
s.logger.Debug("RSA key has been generated")
default: // default to ECDSA
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicDer,
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateDer,
}))
s.logger.Debug("ECDSA key has been generated")
}
return &oauthserver.KeyResult{
PrivatePem: privatePem,
PublicPem: publicPem,
Generated: true,
}, nil
}
// TODO MVP allow specifying a URL to get the public key
// if registration.Key.URL != "" {
// return &oauthserver.KeyResult{
// URL: registration.Key.URL,
// }, nil
// }
if keyOption.PublicPEM != "" {
pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM)
if err != nil {
s.logger.Error("cannot decode base64 encoded PEM string", "error", err)
}
_, err = utils.ParsePublicKeyPem(pemEncoded)
if err != nil {
s.logger.Error("cannot parse PEM encoded string", "error", err)
return nil, err
}
return &oauthserver.KeyResult{
PublicPem: string(pemEncoded),
}, nil
}
return nil, fmt.Errorf("at least one key option must be specified")
}
// saveServiceAccount creates a service account if the service account ID is NoServiceAccountID, otherwise it updates the service account's permissions
func (s *OAuth2ServiceImpl) saveServiceAccount(ctx context.Context, extSvcName string, saID int64, permissions []ac.Permission) (int64, error) {
if saID == oauthserver.NoServiceAccountID {
// Create a service account
s.logger.Debug("Create service account", "external service name", extSvcName)
return s.createServiceAccount(ctx, extSvcName, permissions)
}
// check if the service account exists
s.logger.Debug("Update service account", "external service name", extSvcName)
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, saID)
if err != nil {
s.logger.Error("Error retrieving service account", "external service name", extSvcName, "error", err)
return oauthserver.NoServiceAccountID, err
}
// update the service account's permissions
if len(permissions) > 0 {
s.logger.Debug("Update role permissions", "external service name", extSvcName, "saID", saID)
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
OrgID: ac.GlobalOrgID,
Global: true,
ExternalServiceID: extSvcName,
ServiceAccountID: sa.Id,
Permissions: permissions,
}); err != nil {
return oauthserver.NoServiceAccountID, err
}
return saID, nil
}
// remove the service account
errDelete := s.deleteServiceAccount(ctx, extSvcName, sa.Id)
return oauthserver.NoServiceAccountID, errDelete
}
// deleteServiceAccount deletes a service account by ID and removes its associated role
func (s *OAuth2ServiceImpl) deleteServiceAccount(ctx context.Context, extSvcName string, saID int64) error {
s.logger.Debug("Delete service account", "external service name", extSvcName, "saID", saID)
if err := s.saService.DeleteServiceAccount(ctx, oauthserver.TmpOrgID, saID); err != nil {
return err
}
return s.acService.DeleteExternalServiceRole(ctx, extSvcName)
}
// createServiceAccount creates a service account with the given permissions and returns the ID of the service account
// When no permission is given, the account isn't created and NoServiceAccountID is returned
// This first design does not use a single transaction for the whole service account creation process => database consistency is not guaranteed.
// Consider changing this in the future.
func (s *OAuth2ServiceImpl) createServiceAccount(ctx context.Context, extSvcName string, permissions []ac.Permission) (int64, error) {
if len(permissions) == 0 {
// No permission, no service account
s.logger.Debug("No permission, no service account", "external service name", extSvcName)
return oauthserver.NoServiceAccountID, nil
}
newRole := func(r roletype.RoleType) *roletype.RoleType {
return &r
}
newBool := func(b bool) *bool {
return &b
}
slug := slugify.Slugify(extSvcName)
s.logger.Debug("Generate service account", "external service name", extSvcName, "orgID", oauthserver.TmpOrgID, "name", slug)
sa, err := s.saService.CreateServiceAccount(ctx, oauthserver.TmpOrgID, &serviceaccounts.CreateServiceAccountForm{
Name: slug,
Role: newRole(roletype.RoleViewer), // FIXME: Use empty role
IsDisabled: newBool(false),
})
if err != nil {
return oauthserver.NoServiceAccountID, err
}
s.logger.Debug("create tailored role for service account", "external service name", extSvcName, "name", slug, "service_account_id", sa.Id, "permissions", permissions)
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
OrgID: ac.GlobalOrgID,
Global: true,
ExternalServiceID: slug,
ServiceAccountID: sa.Id,
Permissions: permissions,
}); err != nil {
return oauthserver.NoServiceAccountID, err
}
return sa.Id, nil
}
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
// permissions when impersonation is requested
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *oauthserver.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) {
selfPermissions := []ac.Permission{}
impersonatePermissions := []ac.Permission{}
if registration.Self.Enabled {
selfPermissions = append(selfPermissions, registration.Self.Permissions...)
}
if registration.Impersonation.Enabled {
requiredForToken := []ac.Permission{
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
}
if registration.Impersonation.Groups {
requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
}
impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...)
selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll})
}
return selfPermissions, impersonatePermissions
}
@@ -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
}
+351
View File
@@ -0,0 +1,351 @@
package oasimpl
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/oauthserver"
"github.com/grafana/grafana/pkg/services/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer)
func (s *OAuth2ServiceImpl) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {
// This context will be passed to all methods.
ctx := req.Context()
// Create an empty session object which will be passed to the request handlers
oauthSession := NewAuthSession()
// This will create an access request object and iterate through the registered TokenEndpointHandlers to validate the request.
accessRequest, err := s.oauthProvider.NewAccessRequest(ctx, req, oauthSession)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
client, err := s.GetExternalService(ctx, accessRequest.GetClient().GetID())
if err != nil || client == nil {
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
})
return
}
oauthSession.JWTClaims.Add("client_id", client.ClientID)
errClientCred := s.handleClientCredentials(ctx, accessRequest, oauthSession, client)
if errClientCred != nil {
s.writeAccessError(ctx, rw, accessRequest, errClientCred)
return
}
errJWTBearer := s.handleJWTBearer(ctx, accessRequest, oauthSession, client)
if errJWTBearer != nil {
s.writeAccessError(ctx, rw, accessRequest, errJWTBearer)
return
}
// All tokens we generate in this service should target Grafana's API.
accessRequest.GrantAudience(s.cfg.AppURL)
// Prepare response, fosite handlers will populate the token.
response, err := s.oauthProvider.NewAccessResponse(ctx, accessRequest)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
s.oauthProvider.WriteAccessResponse(ctx, rw, accessRequest, response)
}
// writeAccessError logs the error then uses fosite to write the error back to the user.
func (s *OAuth2ServiceImpl) writeAccessError(ctx context.Context, rw http.ResponseWriter, accessRequest fosite.AccessRequester, err error) {
var fositeErr *fosite.RFC6749Error
if errors.As(err, &fositeErr) {
s.logger.Error("description", fositeErr.DescriptionField, "hint", fositeErr.HintField, "error", fositeErr.ErrorField)
} else {
s.logger.Error("error", err)
}
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, err)
}
// splitOAuthScopes sort scopes that are generic (profile, email, groups, entitlements) from scopes
// that are RBAC actions (used to further restrict the entitlements embedded in the access_token)
func splitOAuthScopes(requestedScopes fosite.Arguments) (map[string]bool, map[string]bool) {
actionsFilter := map[string]bool{}
claimsFilter := map[string]bool{}
for _, scope := range requestedScopes {
switch scope {
case "profile", "email", "groups", "entitlements":
claimsFilter[scope] = true
default:
actionsFilter[scope] = true
}
}
return actionsFilter, claimsFilter
}
// handleJWTBearer populates the "impersonation" access_token generated by fosite to match the rfc9068 specifications (entitlements, groups).
// It ensures that the user can be impersonated, that the generated token audiences only contain Grafana's AppURL (and token endpoint)
// and that entitlements solely contain the user's permissions that the client is allowed to have.
func (s *OAuth2ServiceImpl) handleJWTBearer(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) {
return nil
}
userID, err := utils.ParseUserIDFromSubject(oauthSession.Subject)
if err != nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
// Check audiences list only contains the AppURL and the token endpoint
for _, aud := range accessRequest.GetGrantedAudience() {
if aud != fmt.Sprintf("%voauth2/token", s.cfg.AppURL) && aud != s.cfg.AppURL {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to target this Audience.",
HintField: "The audience must be the AppURL or the token endpoint.",
ErrorField: "invalid_request",
CodeField: http.StatusForbidden,
}
}
}
// If the client was not allowed to impersonate the user we would not have reached this point given allowed scopes would have been empty
// But just in case we check again
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
hasAccess, errAccess := s.accessControl.Evaluate(ctx, client.SignedInUser, ev)
if errAccess != nil || !hasAccess {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to impersonate subject.",
ErrorField: "restricted_access",
CodeField: http.StatusForbidden,
}
}
// Populate claims' suject from the session subject
oauthSession.JWTClaims.Subject = oauthSession.Subject
// Get the user
query := user.GetUserByIDQuery{ID: userID}
dbUser, err := s.userService.GetByID(ctx, &query)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
return &fosite.RFC6749Error{
DescriptionField: "The request subject could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
oauthSession.Username = dbUser.Login
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
teams := []*team.TeamDTO{}
// Fetch teams if the groups scope is requested or if we need to populate it in the entitlements
if claimsFilter["groups"] ||
(claimsFilter["entitlements"] && (len(actionsFilter) == 0 || actionsFilter["teams:read"])) {
var errGetTeams error
teams, errGetTeams = s.teamService.GetTeamsByUser(ctx, &team.GetTeamsByUserQuery{
OrgID: oauthserver.TmpOrgID,
UserID: dbUser.ID,
// Fetch teams without restriction on permissions
SignedInUser: &user.SignedInUser{
OrgID: oauthserver.TmpOrgID,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
ac.ActionTeamsRead: {ac.ScopeTeamsAll},
},
},
},
})
if errGetTeams != nil {
return &fosite.RFC6749Error{
DescriptionField: "The teams scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
}
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", dbUser.Name)
oauthSession.JWTClaims.Add("login", dbUser.Login)
oauthSession.JWTClaims.Add("updated_at", dbUser.Updated.Unix())
}
if claimsFilter["email"] {
oauthSession.JWTClaims.Add("email", dbUser.Email)
}
if claimsFilter["groups"] {
teamNames := make([]string, 0, len(teams))
for _, team := range teams {
teamNames = append(teamNames, team.Name)
}
oauthSession.JWTClaims.Add("groups", teamNames)
}
if claimsFilter["entitlements"] {
// Get the user permissions (apply the actions filter)
permissions, errGetPermission := s.filteredUserPermissions(ctx, userID, actionsFilter)
if errGetPermission != nil {
return errGetPermission
}
// Compute the impersonated permissions (apply the actions filter, replace the scope self with the user id)
impPerms := s.filteredImpersonatePermissions(client.ImpersonatePermissions, userID, teams, actionsFilter)
// Intersect the permissions with the client permissions
intesect := ac.Intersect(permissions, impPerms)
oauthSession.JWTClaims.Add("entitlements", intesect)
}
return nil
}
// filteredUserPermissions gets the user permissions and applies the actions filter
func (s *OAuth2ServiceImpl) filteredUserPermissions(ctx context.Context, userID int64, actionsFilter map[string]bool) ([]ac.Permission, error) {
permissions, err := s.acService.SearchUserPermissions(ctx, oauthserver.TmpOrgID, ac.SearchOptions{UserID: userID})
if err != nil {
return nil, &fosite.RFC6749Error{
DescriptionField: "The permissions scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range permissions {
if actionsFilter[permissions[i].Action] {
filtered = append(filtered, permissions[i])
}
}
permissions = filtered
}
return permissions, nil
}
// filteredImpersonatePermissions computes the impersonated permissions.
// It applies the actions filter and replaces the "self RBAC scopes" (~ scope templates) by the correct user id/team id.
func (*OAuth2ServiceImpl) filteredImpersonatePermissions(impersonatePermissions []ac.Permission, userID int64, teams []*team.TeamDTO, actionsFilter map[string]bool) []ac.Permission {
// Compute the impersonated permissions
impPerms := impersonatePermissions
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range impPerms {
if actionsFilter[impPerms[i].Action] {
filtered = append(filtered, impPerms[i])
}
}
impPerms = filtered
}
// Replace the scope self with the user id
correctScopes := []ac.Permission{}
for i := range impPerms {
switch impPerms[i].Scope {
case oauthserver.ScopeGlobalUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("global.users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeTeamsSelf:
for t := range teams {
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("teams", "id", strconv.FormatInt(teams[t].ID, 10)),
})
}
default:
correctScopes = append(correctScopes, impPerms[i])
}
continue
}
return correctScopes
}
// handleClientCredentials populates the client's access_token generated by fosite to match the rfc9068 specifications (entitlements, groups)
func (s *OAuth2ServiceImpl) handleClientCredentials(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.ExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne("client_credentials") {
return nil
}
// Set the subject to the service account associated to the client
oauthSession.JWTClaims.Subject = fmt.Sprintf("user:id:%d", client.ServiceAccountID)
sa := client.SignedInUser
if sa == nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the service account of the client",
ErrorField: "not_found",
CodeField: http.StatusNotFound,
}
}
oauthSession.Username = sa.Login
// For client credentials, scopes are not marked as granted by fosite but the request would have been rejected
// already if the client was not allowed to request them
for _, scope := range accessRequest.GetRequestedScopes() {
accessRequest.GrantScope(scope)
}
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", sa.Name)
oauthSession.JWTClaims.Add("login", sa.Login)
}
if claimsFilter["email"] {
s.logger.Debug("Service accounts have no emails")
}
if claimsFilter["groups"] {
s.logger.Debug("Service accounts have no groups")
}
if claimsFilter["entitlements"] {
s.logger.Debug("Processing client entitlements")
if sa.Permissions != nil && sa.Permissions[oauthserver.TmpOrgID] != nil {
perms := sa.Permissions[oauthserver.TmpOrgID]
if len(actionsFilter) > 0 {
filtered := map[string][]string{}
for action := range actionsFilter {
if _, ok := perms[action]; ok {
filtered[action] = perms[action]
}
}
perms = filtered
}
oauthSession.JWTClaims.Add("entitlements", perms)
} else {
s.logger.Debug("Client has no permissions")
}
}
return nil
}
@@ -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
}