mirror of https://github.com/grafana/grafana
AuthN: Remove embedded oauth server (#83146)
* AuthN: Remove embedded oauth server * Restore main * go mod tidy * Fix problem * Remove permission intersection * Fix test and lint * Fix TestData test * Revert to origin/main * Update go.mod * Update go.mod * Update go.sumpull/83418/head
parent
d0679f0993
commit
80d6bf6da0
@ -1,37 +0,0 @@ |
||||
{ |
||||
"id": "grafana-test-datasource", |
||||
"type": "datasource", |
||||
"name": "Test", |
||||
"backend": true, |
||||
"executable": "gpx_test_datasource", |
||||
"info": { |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
}, |
||||
"logos": { |
||||
"large": "img/ds.svg", |
||||
"small": "img/ds.svg" |
||||
}, |
||||
"screenshots": [], |
||||
"updated": "2023-08-03", |
||||
"version": "1.0.0" |
||||
}, |
||||
"iam": { |
||||
"impersonation": { |
||||
"groups" : true, |
||||
"permissions" : [ |
||||
{ |
||||
"action": "read", |
||||
"scope": "datasource" |
||||
} |
||||
] |
||||
}, |
||||
"permissions" : [ |
||||
{ |
||||
"action": "read", |
||||
"scope": "datasource" |
||||
} |
||||
] |
||||
} |
||||
} |
@ -1,37 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"github.com/grafana/grafana/pkg/api/routing" |
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
) |
||||
|
||||
type api struct { |
||||
router routing.RouteRegister |
||||
oauthServer oauthserver.OAuth2Server |
||||
} |
||||
|
||||
func NewAPI( |
||||
router routing.RouteRegister, |
||||
oauthServer oauthserver.OAuth2Server, |
||||
) *api { |
||||
return &api{ |
||||
router: router, |
||||
oauthServer: oauthServer, |
||||
} |
||||
} |
||||
|
||||
func (a *api) RegisterAPIEndpoints() { |
||||
a.router.Group("/oauth2", func(oauthRouter routing.RouteRegister) { |
||||
oauthRouter.Post("/introspect", a.handleIntrospectionRequest) |
||||
oauthRouter.Post("/token", a.handleTokenRequest) |
||||
}) |
||||
} |
||||
|
||||
func (a *api) handleTokenRequest(c *contextmodel.ReqContext) { |
||||
a.oauthServer.HandleTokenRequest(c.Resp, c.Req) |
||||
} |
||||
|
||||
func (a *api) handleIntrospectionRequest(c *contextmodel.ReqContext) { |
||||
a.oauthServer.HandleIntrospectionRequest(c.Resp, c.Req) |
||||
} |
@ -1,25 +0,0 @@ |
||||
package oauthserver |
||||
|
||||
import ( |
||||
"github.com/grafana/grafana/pkg/util/errutil" |
||||
) |
||||
|
||||
var ( |
||||
ErrClientNotFoundMessageID = "oauthserver.client-not-found" |
||||
) |
||||
|
||||
var ( |
||||
ErrClientRequiredID = errutil.BadRequest( |
||||
"oauthserver.required-client-id", |
||||
errutil.WithPublicMessage("client ID is required")).Errorf("Client ID is required") |
||||
ErrClientRequiredName = errutil.BadRequest( |
||||
"oauthserver.required-client-name", |
||||
errutil.WithPublicMessage("client name is required")).Errorf("Client name is required") |
||||
ErrClientNotFound = errutil.NotFound( |
||||
ErrClientNotFoundMessageID, |
||||
errutil.WithPublicMessage("Requested client has not been found")) |
||||
) |
||||
|
||||
func ErrClientNotFoundFn(clientID string) error { |
||||
return ErrClientNotFound.Errorf("client '%s' not found", clientID) |
||||
} |
@ -1,153 +0,0 @@ |
||||
package oauthserver |
||||
|
||||
import ( |
||||
"context" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/ory/fosite" |
||||
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
) |
||||
|
||||
type OAuthExternalService struct { |
||||
ID int64 `xorm:"id pk autoincr"` |
||||
Name string `xorm:"name"` |
||||
ClientID string `xorm:"client_id"` |
||||
Secret string `xorm:"secret"` |
||||
RedirectURI string `xorm:"redirect_uri"` // Not used yet (code flow)
|
||||
GrantTypes string `xorm:"grant_types"` // CSV value
|
||||
Audiences string `xorm:"audiences"` // CSV value
|
||||
PublicPem []byte `xorm:"public_pem"` |
||||
ServiceAccountID int64 `xorm:"service_account_id"` |
||||
// SelfPermissions are the registered service account permissions (registered and managed permissions)
|
||||
SelfPermissions []ac.Permission |
||||
// ImpersonatePermissions is the restriction set of permissions while impersonating
|
||||
ImpersonatePermissions []ac.Permission |
||||
|
||||
// SignedInUser refers to the current Service Account identity/user
|
||||
SignedInUser *user.SignedInUser |
||||
Scopes []string |
||||
ImpersonateScopes []string |
||||
} |
||||
|
||||
// ToExternalService converts the ExternalService (used internally by the oauthserver) to extsvcauth.ExternalService (used outside the package)
|
||||
// If object must contain Key pairs, pass them as parameters, otherwise only the client PublicPem will be added.
|
||||
func (c *OAuthExternalService) ToExternalService(keys *extsvcauth.KeyResult) *extsvcauth.ExternalService { |
||||
c2 := &extsvcauth.ExternalService{ |
||||
ID: c.ClientID, |
||||
Name: c.Name, |
||||
Secret: c.Secret, |
||||
OAuthExtra: &extsvcauth.OAuthExtra{ |
||||
GrantTypes: c.GrantTypes, |
||||
Audiences: c.Audiences, |
||||
RedirectURI: c.RedirectURI, |
||||
KeyResult: keys, |
||||
}, |
||||
} |
||||
|
||||
// Fallback to only display the public pem
|
||||
if keys == nil && len(c.PublicPem) > 0 { |
||||
c2.OAuthExtra.KeyResult = &extsvcauth.KeyResult{PublicPem: string(c.PublicPem)} |
||||
} |
||||
|
||||
return c2 |
||||
} |
||||
|
||||
func (c *OAuthExternalService) LogID() string { |
||||
return "{name: " + c.Name + ", clientID: " + c.ClientID + "}" |
||||
} |
||||
|
||||
// GetID returns the client ID.
|
||||
func (c *OAuthExternalService) GetID() string { return c.ClientID } |
||||
|
||||
// GetHashedSecret returns the hashed secret as it is stored in the store.
|
||||
func (c *OAuthExternalService) GetHashedSecret() []byte { |
||||
// Hashed version is stored in the secret field
|
||||
return []byte(c.Secret) |
||||
} |
||||
|
||||
// GetRedirectURIs returns the client's allowed redirect URIs.
|
||||
func (c *OAuthExternalService) GetRedirectURIs() []string { |
||||
return []string{c.RedirectURI} |
||||
} |
||||
|
||||
// GetGrantTypes returns the client's allowed grant types.
|
||||
func (c *OAuthExternalService) GetGrantTypes() fosite.Arguments { |
||||
return strings.Split(c.GrantTypes, ",") |
||||
} |
||||
|
||||
// GetResponseTypes returns the client's allowed response types.
|
||||
// All allowed combinations of response types have to be listed, each combination having
|
||||
// response types of the combination separated by a space.
|
||||
func (c *OAuthExternalService) GetResponseTypes() fosite.Arguments { |
||||
return fosite.Arguments{"code"} |
||||
} |
||||
|
||||
// GetScopes returns the scopes this client is allowed to request on its own behalf.
|
||||
func (c *OAuthExternalService) GetScopes() fosite.Arguments { |
||||
if c.Scopes != nil { |
||||
return c.Scopes |
||||
} |
||||
|
||||
ret := []string{"profile", "email", "groups", "entitlements"} |
||||
if c.SignedInUser != nil && c.SignedInUser.Permissions != nil { |
||||
perms := c.SignedInUser.Permissions[TmpOrgID] |
||||
for action := range perms { |
||||
// Add all actions that the plugin is allowed to request
|
||||
ret = append(ret, action) |
||||
} |
||||
} |
||||
|
||||
c.Scopes = ret |
||||
return ret |
||||
} |
||||
|
||||
// GetScopes returns the scopes this client is allowed to request on a specific user.
|
||||
func (c *OAuthExternalService) GetScopesOnUser(ctx context.Context, accessControl ac.AccessControl, userID int64) []string { |
||||
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10))) |
||||
hasAccess, errAccess := accessControl.Evaluate(ctx, c.SignedInUser, ev) |
||||
if errAccess != nil || !hasAccess { |
||||
return nil |
||||
} |
||||
|
||||
if c.ImpersonateScopes != nil { |
||||
return c.ImpersonateScopes |
||||
} |
||||
|
||||
ret := []string{} |
||||
if c.ImpersonatePermissions != nil { |
||||
perms := c.ImpersonatePermissions |
||||
for i := range perms { |
||||
if perms[i].Action == ac.ActionUsersRead && perms[i].Scope == ScopeGlobalUsersSelf { |
||||
ret = append(ret, "profile", "email", ac.ActionUsersRead) |
||||
continue |
||||
} |
||||
if perms[i].Action == ac.ActionUsersPermissionsRead && perms[i].Scope == ScopeUsersSelf { |
||||
ret = append(ret, "entitlements", ac.ActionUsersPermissionsRead) |
||||
continue |
||||
} |
||||
if perms[i].Action == ac.ActionTeamsRead && perms[i].Scope == ScopeTeamsSelf { |
||||
ret = append(ret, "groups", ac.ActionTeamsRead) |
||||
continue |
||||
} |
||||
// Add all actions that the plugin is allowed to request
|
||||
ret = append(ret, perms[i].Action) |
||||
} |
||||
} |
||||
|
||||
c.ImpersonateScopes = ret |
||||
return ret |
||||
} |
||||
|
||||
// IsPublic returns true, if this client is marked as public.
|
||||
func (c *OAuthExternalService) IsPublic() bool { |
||||
return false |
||||
} |
||||
|
||||
// GetAudience returns the allowed audience(s) for this client.
|
||||
func (c *OAuthExternalService) GetAudience() fosite.Arguments { |
||||
return strings.Split(c.Audiences, ",") |
||||
} |
@ -1,213 +0,0 @@ |
||||
package oauthserver |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
) |
||||
|
||||
func setupTestEnv(t *testing.T) *OAuthExternalService { |
||||
t.Helper() |
||||
|
||||
client := &OAuthExternalService{ |
||||
Name: "my-ext-service", |
||||
ClientID: "RANDOMID", |
||||
Secret: "RANDOMSECRET", |
||||
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer", |
||||
ServiceAccountID: 2, |
||||
SelfPermissions: []ac.Permission{ |
||||
{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}, |
||||
}, |
||||
SignedInUser: &user.SignedInUser{ |
||||
UserID: 2, |
||||
OrgID: 1, |
||||
}, |
||||
} |
||||
return client |
||||
} |
||||
|
||||
func TestExternalService_GetScopesOnUser(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
impersonatePermissions []ac.Permission |
||||
initTestEnv func(*OAuthExternalService) |
||||
expectedScopes []string |
||||
}{ |
||||
{ |
||||
name: "should return nil when the service account has no impersonate permissions", |
||||
expectedScopes: nil, |
||||
}, |
||||
{ |
||||
name: "should return the 'profile', 'email' and associated RBAC action", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, |
||||
}, |
||||
} |
||||
c.ImpersonatePermissions = []ac.Permission{ |
||||
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf}, |
||||
} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", ac.ActionUsersRead}, |
||||
}, |
||||
{ |
||||
name: "should return 'entitlements' and associated RBAC action scopes", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, |
||||
}, |
||||
} |
||||
c.ImpersonatePermissions = []ac.Permission{ |
||||
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf}, |
||||
} |
||||
}, |
||||
expectedScopes: []string{"entitlements", ac.ActionUsersPermissionsRead}, |
||||
}, |
||||
{ |
||||
name: "should return 'groups' and associated RBAC action scopes", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, |
||||
}, |
||||
} |
||||
c.ImpersonatePermissions = []ac.Permission{ |
||||
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf}, |
||||
} |
||||
}, |
||||
expectedScopes: []string{"groups", ac.ActionTeamsRead}, |
||||
}, |
||||
{ |
||||
name: "should return all scopes", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, |
||||
}, |
||||
} |
||||
c.ImpersonatePermissions = []ac.Permission{ |
||||
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf}, |
||||
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf}, |
||||
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf}, |
||||
{Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll}, |
||||
} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", ac.ActionUsersRead, |
||||
"entitlements", ac.ActionUsersPermissionsRead, |
||||
"groups", ac.ActionTeamsRead, |
||||
"dashboards:read"}, |
||||
}, |
||||
{ |
||||
name: "should return stored scopes when the client's impersonate scopes has already been set", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
ac.ActionUsersImpersonate: {ac.ScopeUsersAll}, |
||||
}, |
||||
} |
||||
c.ImpersonateScopes = []string{"dashboard:create", "profile", "email", "entitlements", "groups"} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboard:create"}, |
||||
}, |
||||
} |
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
c := setupTestEnv(t) |
||||
if tc.initTestEnv != nil { |
||||
tc.initTestEnv(c) |
||||
} |
||||
scopes := c.GetScopesOnUser(context.Background(), acimpl.ProvideAccessControl(setting.NewCfg()), 3) |
||||
require.ElementsMatch(t, tc.expectedScopes, scopes) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestExternalService_GetScopes(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
impersonatePermissions []ac.Permission |
||||
initTestEnv func(*OAuthExternalService) |
||||
expectedScopes []string |
||||
}{ |
||||
{ |
||||
name: "should return default scopes when the signed in user is nil", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser = nil |
||||
}, |
||||
expectedScopes: []string{"profile", "email", "entitlements", "groups"}, |
||||
}, |
||||
{ |
||||
name: "should return default scopes when the signed in user has no permissions", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", "entitlements", "groups"}, |
||||
}, |
||||
{ |
||||
name: "should return additional scopes from signed in user's permissions", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.SignedInUser.Permissions = map[int64]map[string][]string{ |
||||
1: { |
||||
dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, |
||||
}, |
||||
} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboards:read"}, |
||||
}, |
||||
{ |
||||
name: "should return stored scopes when the client's scopes has already been set", |
||||
initTestEnv: func(c *OAuthExternalService) { |
||||
c.Scopes = []string{"profile", "email", "entitlements", "groups"} |
||||
}, |
||||
expectedScopes: []string{"profile", "email", "entitlements", "groups"}, |
||||
}, |
||||
} |
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
c := setupTestEnv(t) |
||||
if tc.initTestEnv != nil { |
||||
tc.initTestEnv(c) |
||||
} |
||||
scopes := c.GetScopes() |
||||
require.ElementsMatch(t, tc.expectedScopes, scopes) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestExternalService_ToDTO(t *testing.T) { |
||||
client := &OAuthExternalService{ |
||||
ID: 1, |
||||
Name: "my-ext-service", |
||||
ClientID: "test", |
||||
Secret: "testsecret", |
||||
RedirectURI: "http://localhost:3000", |
||||
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer", |
||||
Audiences: "https://example.org,https://second.example.org", |
||||
PublicPem: []byte("pem_encoded_public_key"), |
||||
} |
||||
|
||||
dto := client.ToExternalService(nil) |
||||
|
||||
require.Equal(t, client.ClientID, dto.ID) |
||||
require.Equal(t, client.Name, dto.Name) |
||||
require.Equal(t, client.Secret, dto.Secret) |
||||
|
||||
require.NotNil(t, dto.OAuthExtra) |
||||
|
||||
require.Equal(t, client.RedirectURI, dto.OAuthExtra.RedirectURI) |
||||
require.Equal(t, client.GrantTypes, dto.OAuthExtra.GrantTypes) |
||||
require.Equal(t, client.Audiences, dto.OAuthExtra.Audiences) |
||||
require.Equal(t, client.PublicPem, []byte(dto.OAuthExtra.KeyResult.PublicPem)) |
||||
require.Empty(t, dto.OAuthExtra.KeyResult.PrivatePem) |
||||
require.Empty(t, dto.OAuthExtra.KeyResult.URL) |
||||
require.False(t, dto.OAuthExtra.KeyResult.Generated) |
||||
} |
@ -1,58 +0,0 @@ |
||||
package oauthserver |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth" |
||||
"gopkg.in/square/go-jose.v2" |
||||
) |
||||
|
||||
const ( |
||||
// TmpOrgID is the orgID we use while global service accounts are not supported.
|
||||
TmpOrgID int64 = 1 |
||||
// NoServiceAccountID is the ID we use for client that have no service account associated.
|
||||
NoServiceAccountID int64 = 0 |
||||
|
||||
// List of scopes used to identify the impersonated user.
|
||||
ScopeUsersSelf = "users:self" |
||||
ScopeGlobalUsersSelf = "global.users:self" |
||||
ScopeTeamsSelf = "teams:self" |
||||
|
||||
// Supported encryptions
|
||||
RS256 = "RS256" |
||||
ES256 = "ES256" |
||||
) |
||||
|
||||
// OAuth2Server represents a service in charge of managing OAuth2 clients
|
||||
// and handling OAuth2 requests (token, introspection).
|
||||
type OAuth2Server interface { |
||||
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
|
||||
// it ensures that the associated service account has the correct permissions.
|
||||
SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) |
||||
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
|
||||
// SignedInUser from the associated service account.
|
||||
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) |
||||
// RemoveExternalService removes an external service and its associated resources from the store.
|
||||
RemoveExternalService(ctx context.Context, name string) error |
||||
|
||||
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
|
||||
// grant (ex: client_credentials, jwtbearer).
|
||||
HandleTokenRequest(rw http.ResponseWriter, req *http.Request) |
||||
// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and
|
||||
// to determine meta-information about this token.
|
||||
HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) |
||||
} |
||||
|
||||
//go:generate mockery --name Store --structname MockStore --outpkg oastest --filename store_mock.go --output ./oastest/
|
||||
|
||||
type Store interface { |
||||
DeleteExternalService(ctx context.Context, id string) error |
||||
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) |
||||
GetExternalServiceNames(ctx context.Context) ([]string, error) |
||||
GetExternalServiceByName(ctx context.Context, name string) (*OAuthExternalService, error) |
||||
GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) |
||||
RegisterExternalService(ctx context.Context, client *OAuthExternalService) error |
||||
SaveExternalService(ctx context.Context, client *OAuthExternalService) error |
||||
UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error |
||||
} |
@ -1,162 +0,0 @@ |
||||
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/extsvcauth/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) |
||||
} |
@ -1,119 +0,0 @@ |
||||
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/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
) |
||||
|
||||
var cachedExternalService = func() *oauthserver.OAuthExternalService { |
||||
return &oauthserver.OAuthExternalService{ |
||||
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.ErrClientNotFoundFn("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.ElementsMatch(t, tc.expectedScopes, scopes) |
||||
}) |
||||
} |
||||
} |
@ -1,21 +0,0 @@ |
||||
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) |
||||
} |
@ -1,500 +0,0 @@ |
||||
package oasimpl |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/ecdsa" |
||||
"crypto/elliptic" |
||||
"crypto/rand" |
||||
"crypto/rsa" |
||||
"crypto/x509" |
||||
"encoding/base64" |
||||
"encoding/pem" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/go-jose/go-jose/v3" |
||||
"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/bus" |
||||
"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" |
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/api" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/store" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" |
||||
"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" |
||||
) |
||||
|
||||
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.ExtSvcAccountsService |
||||
userService user.Service |
||||
teamService team.Service |
||||
publicKey any |
||||
} |
||||
|
||||
func ProvideService(router routing.RouteRegister, bus bus.Bus, db db.DB, cfg *setting.Cfg, |
||||
extSvcAccSvc serviceaccounts.ExtSvcAccountsService, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service, |
||||
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) { |
||||
if !fmgmt.IsEnabledGlobally(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, |
||||
} |
||||
|
||||
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: extSvcAccSvc, |
||||
teamService: teamSvc, |
||||
} |
||||
|
||||
api := api.NewAPI(router, s) |
||||
api.RegisterAPIEndpoints() |
||||
|
||||
bus.AddEventListener(s.handlePluginStateChanged) |
||||
|
||||
s.oauthProvider = newProvider(config, s, keySvc) |
||||
|
||||
return s, nil |
||||
} |
||||
|
||||
func newProvider(config *fosite.Config, storage any, signingKeyService signingkeys.Service) fosite.OAuth2Provider { |
||||
keyGetter := func(ctx context.Context) (any, error) { |
||||
_, key, err := signingKeyService.GetOrCreatePrivateKey(ctx, signingkeys.ServerPrivateKeyID, jose.ES256) |
||||
return key, err |
||||
} |
||||
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, |
||||
) |
||||
} |
||||
|
||||
// HasExternalService returns whether an external service has been saved with that name.
|
||||
func (s *OAuth2ServiceImpl) HasExternalService(ctx context.Context, name string) (bool, error) { |
||||
client, errRetrieve := s.sqlstore.GetExternalServiceByName(ctx, name) |
||||
if errRetrieve != nil && !errors.Is(errRetrieve, oauthserver.ErrClientNotFound) { |
||||
return false, errRetrieve |
||||
} |
||||
|
||||
return client != nil, nil |
||||
} |
||||
|
||||
// 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.OAuthExternalService, error) { |
||||
entry, ok := s.cache.Get(id) |
||||
if ok { |
||||
client, ok := entry.(oauthserver.OAuthExternalService) |
||||
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 |
||||
} |
||||
|
||||
if err := s.setClientUser(ctx, client); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
s.cache.Set(id, *client, cacheExpirationTime) |
||||
return client, nil |
||||
} |
||||
|
||||
// setClientUser sets the SignedInUser and SelfPermissions fields of the client
|
||||
func (s *OAuth2ServiceImpl) setClientUser(ctx context.Context, client *oauthserver.OAuthExternalService) error { |
||||
if client.ServiceAccountID == oauthserver.NoServiceAccountID { |
||||
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "client_id", client.ClientID, "name", client.Name) |
||||
|
||||
// Create a signed in user with no role and no permission
|
||||
client.SignedInUser = &user.SignedInUser{ |
||||
UserID: oauthserver.NoServiceAccountID, |
||||
OrgID: oauthserver.TmpOrgID, |
||||
Name: client.Name, |
||||
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}}, |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
s.logger.Debug("GetExternalService: fetch permissions", "client_id", client.ClientID) |
||||
sa, err := s.saService.RetrieveExtSvcAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID) |
||||
if err != nil { |
||||
s.logger.Error("GetExternalService: error fetching service account", "id", client.ClientID, "error", err) |
||||
return err |
||||
} |
||||
client.SignedInUser = &user.SignedInUser{ |
||||
UserID: sa.ID, |
||||
OrgID: oauthserver.TmpOrgID, |
||||
OrgRole: sa.Role, |
||||
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", "client_id", client.ClientID, "error", err) |
||||
return err |
||||
} |
||||
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions) |
||||
return nil |
||||
} |
||||
|
||||
// GetExternalServiceNames get the names of External Service in store
|
||||
func (s *OAuth2ServiceImpl) GetExternalServiceNames(ctx context.Context) ([]string, error) { |
||||
s.logger.Debug("Get external service names from store") |
||||
res, err := s.sqlstore.GetExternalServiceNames(ctx) |
||||
if err != nil { |
||||
s.logger.Error("Could not fetch clients from store", "error", err.Error()) |
||||
return nil, err |
||||
} |
||||
return res, nil |
||||
} |
||||
|
||||
func (s *OAuth2ServiceImpl) RemoveExternalService(ctx context.Context, name string) error { |
||||
s.logger.Info("Remove external service", "service", name) |
||||
|
||||
client, err := s.sqlstore.GetExternalServiceByName(ctx, name) |
||||
if err != nil { |
||||
if errors.Is(err, oauthserver.ErrClientNotFound) { |
||||
s.logger.Debug("No external service linked to this name", "name", name) |
||||
return nil |
||||
} |
||||
s.logger.Error("Error fetching external service", "name", name, "error", err.Error()) |
||||
return err |
||||
} |
||||
|
||||
// Since we will delete the service, clear cache entry
|
||||
s.cache.Delete(client.ClientID) |
||||
|
||||
// Delete the OAuth client info in store
|
||||
if err := s.sqlstore.DeleteExternalService(ctx, client.ClientID); err != nil { |
||||
s.logger.Error("Error deleting external service", "name", name, "error", err.Error()) |
||||
return err |
||||
} |
||||
s.logger.Debug("Deleted external service", "name", name, "client_id", client.ClientID) |
||||
|
||||
// Remove the associated service account
|
||||
return s.saService.RemoveExtSvcAccount(ctx, oauthserver.TmpOrgID, slugify.Slugify(name)) |
||||
} |
||||
|
||||
// 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 *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { |
||||
if registration == nil { |
||||
s.logger.Warn("RegisterExternalService called without registration") |
||||
return nil, nil |
||||
} |
||||
slug := registration.Name |
||||
s.logger.Info("Registering external service", "external service", slug) |
||||
|
||||
// Check if the client already exists in store
|
||||
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, slug) |
||||
if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) { |
||||
s.logger.Error("Error fetching service", "external service", slug, "error", errFetchExtSvc) |
||||
return nil, errFetchExtSvc |
||||
} |
||||
// Otherwise, create a new client
|
||||
if client == nil { |
||||
s.logger.Debug("External service does not yet exist", "external service", slug) |
||||
client = &oauthserver.OAuthExternalService{ |
||||
Name: slug, |
||||
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.OAuthProviderCfg == nil { |
||||
return nil, errors.New("missing oauth provider configuration") |
||||
} |
||||
|
||||
if registration.OAuthProviderCfg.RedirectURI != nil { |
||||
client.RedirectURI = *registration.OAuthProviderCfg.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 |
||||
} |
||||
|
||||
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.OAuthProviderCfg.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.ToExternalService(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) |
||||
|
||||
s.logger.Debug("Save service account") |
||||
saID, errSaveServiceAccount := s.saService.ManageExtSvcAccount(ctx, &serviceaccounts.ManageExtSvcAccountCmd{ |
||||
ExtSvcSlug: slugify.Slugify(client.Name), |
||||
Enabled: registration.Self.Enabled, |
||||
OrgID: oauthserver.TmpOrgID, |
||||
Permissions: client.SelfPermissions, |
||||
}) |
||||
if errSaveServiceAccount != nil { |
||||
return nil, errSaveServiceAccount |
||||
} |
||||
client.ServiceAccountID = saID |
||||
|
||||
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 *extsvcauth.KeyOption) (*extsvcauth.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 &extsvcauth.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 &extsvcauth.KeyResult{ |
||||
PublicPem: string(pemEncoded), |
||||
}, nil |
||||
} |
||||
|
||||
return nil, fmt.Errorf("at least one key option must be specified") |
||||
} |
||||
|
||||
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
|
||||
// permissions when impersonation is requested
|
||||
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *extsvcauth.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) { |
||||
selfPermissions := registration.Self.Permissions |
||||
impersonatePermissions := []ac.Permission{} |
||||
|
||||
if len(registration.Impersonation.Permissions) > 0 { |
||||
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 |
||||
} |
||||
|
||||
// handlePluginStateChanged reset the client authorized grant_types according to the plugin state
|
||||
func (s *OAuth2ServiceImpl) handlePluginStateChanged(ctx context.Context, event *pluginsettings.PluginStateChangedEvent) error { |
||||
s.logger.Debug("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) |
||||
|
||||
if event.OrgId != extsvcauth.TmpOrgID { |
||||
s.logger.Debug("External Service not tied to this organization", "OrgId", event.OrgId) |
||||
return nil |
||||
} |
||||
|
||||
// Retrieve client associated to the plugin
|
||||
client, err := s.sqlstore.GetExternalServiceByName(ctx, event.PluginId) |
||||
if err != nil { |
||||
if errors.Is(err, oauthserver.ErrClientNotFound) { |
||||
s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId) |
||||
return nil |
||||
} |
||||
s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err.Error()) |
||||
return err |
||||
} |
||||
|
||||
// Since we will change the grants, clear cache entry
|
||||
s.cache.Delete(client.ClientID) |
||||
|
||||
if !event.Enabled { |
||||
// Plugin is disabled => remove all grant_types
|
||||
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, "") |
||||
} |
||||
|
||||
if err := s.setClientUser(ctx, client); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// The plugin has self permissions (not only impersonate)
|
||||
canOnlyImpersonate := len(client.SelfPermissions) == 1 && (client.SelfPermissions[0].Action == ac.ActionUsersImpersonate) |
||||
selfEnabled := len(client.SelfPermissions) > 0 && !canOnlyImpersonate |
||||
// The plugin declared impersonate permissions
|
||||
impersonateEnabled := len(client.ImpersonatePermissions) > 0 |
||||
|
||||
grantTypes := s.computeGrantTypes(selfEnabled, impersonateEnabled) |
||||
|
||||
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, strings.Join(grantTypes, ",")) |
||||
} |
@ -1,625 +0,0 @@ |
||||
package oasimpl |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/rand" |
||||
"crypto/rsa" |
||||
"encoding/base64" |
||||
"fmt" |
||||
"slices" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/ory/fosite" |
||||
"github.com/ory/fosite/storage" |
||||
"github.com/stretchr/testify/mock" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache" |
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
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/extsvcauth" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/org" |
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" |
||||
sa "github.com/grafana/grafana/pkg/services/serviceaccounts" |
||||
saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" |
||||
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest" |
||||
"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.MockExtSvcAccountsService |
||||
} |
||||
|
||||
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.NewMockExtSvcAccountsService(t), |
||||
} |
||||
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, &signingkeystest.FakeSigningKeysService{ |
||||
ExpectedSinger: pk, |
||||
ExpectedKeyID: "default", |
||||
ExpectedError: nil, |
||||
}) |
||||
|
||||
return env |
||||
} |
||||
|
||||
func TestOAuth2ServiceImpl_SaveExternalService(t *testing.T) { |
||||
const serviceName = "my-ext-service" |
||||
|
||||
tests := []struct { |
||||
name string |
||||
init func(*TestEnv) |
||||
cmd *extsvcauth.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.ErrClientNotFoundFn(serviceName)) |
||||
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil) |
||||
|
||||
// Return a service account ID
|
||||
env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(0), nil) |
||||
}, |
||||
cmd: &extsvcauth.ExternalServiceRegistration{ |
||||
Name: serviceName, |
||||
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.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.OAuthExternalService) 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 allow client credentials grant with correct permissions", |
||||
init: func(env *TestEnv) { |
||||
// No client at the beginning
|
||||
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) |
||||
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil) |
||||
|
||||
// Return a service account ID
|
||||
env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil) |
||||
}, |
||||
cmd: &extsvcauth.ExternalServiceRegistration{ |
||||
Name: serviceName, |
||||
Self: extsvcauth.SelfCfg{ |
||||
Enabled: true, |
||||
Permissions: []ac.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}}, |
||||
}, |
||||
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.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.OAuthExternalService) bool { |
||||
return client.Name == serviceName && len(client.ClientID) > 0 && len(client.Secret) > 0 && |
||||
client.GrantTypes == string(fosite.GrantTypeClientCredentials) && |
||||
len(client.PublicPem) > 0 && client.ServiceAccountID == 10 && |
||||
len(client.ImpersonatePermissions) == 0 && |
||||
len(client.SelfPermissions) > 0 |
||||
})) |
||||
// Check that despite no credential_grants the service account still has a permission to impersonate users
|
||||
env.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything, |
||||
mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) bool { |
||||
return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll} |
||||
})) |
||||
}, |
||||
}, |
||||
{ |
||||
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.ErrClientNotFoundFn(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("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil) |
||||
}, |
||||
cmd: &extsvcauth.ExternalServiceRegistration{ |
||||
Name: serviceName, |
||||
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}, |
||||
Impersonation: extsvcauth.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.OAuthExternalService) 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.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything, |
||||
mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) 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.OAuthProviderCfg.Key != nil && tt.cmd.OAuthProviderCfg.Key.Generate { |
||||
require.NotNil(t, dto.OAuthExtra.KeyResult) |
||||
require.True(t, dto.OAuthExtra.KeyResult.Generated) |
||||
require.NotEmpty(t, dto.OAuthExtra.KeyResult.PublicPem) |
||||
require.NotEmpty(t, dto.OAuthExtra.KeyResult.PrivatePem) |
||||
} |
||||
|
||||
// Check that we computed grant types and created or updated the service account
|
||||
if tt.cmd.Self.Enabled { |
||||
require.NotNil(t, dto.OAuthExtra.GrantTypes) |
||||
require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should contain client_credentials") |
||||
} else { |
||||
require.NotContains(t, dto.OAuthExtra.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.OAuthExtra.GrantTypes) |
||||
require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should contain JWT Bearer grant") |
||||
} else { |
||||
require.NotContains(t, dto.OAuthExtra.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.OAuthExternalService { |
||||
return &oauthserver.OAuthExternalService{ |
||||
Name: serviceName, |
||||
ClientID: "RANDOMID", |
||||
Secret: "RANDOMSECRET", |
||||
GrantTypes: "client_credentials", |
||||
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"), |
||||
ServiceAccountID: 1, |
||||
} |
||||
} |
||||
cachedClient := &oauthserver.OAuthExternalService{ |
||||
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.ErrClientNotFoundFn(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("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, sa.ErrServiceAccountNotFound) |
||||
}, |
||||
mockChecks: func(t *testing.T, env *TestEnv) { |
||||
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything) |
||||
env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", 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("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, 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, "RetrieveExtSvcAccount", 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("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{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, "RetrieveExtSvcAccount", 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.OAuthExternalService{ |
||||
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 TestOAuth2ServiceImpl_RemoveExternalService(t *testing.T) { |
||||
const serviceName = "my-ext-service" |
||||
const clientID = "RANDOMID" |
||||
|
||||
dummyClient := &oauthserver.OAuthExternalService{ |
||||
Name: serviceName, |
||||
ClientID: clientID, |
||||
ServiceAccountID: 1, |
||||
} |
||||
|
||||
testCases := []struct { |
||||
name string |
||||
init func(*TestEnv) |
||||
}{ |
||||
{ |
||||
name: "should do nothing on not found", |
||||
init: func(env *TestEnv) { |
||||
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName)) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "should remove the external service and its associated service account", |
||||
init: func(env *TestEnv) { |
||||
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(dummyClient, nil) |
||||
env.OAuthStore.On("DeleteExternalService", mock.Anything, clientID).Return(nil) |
||||
env.SAService.On("RemoveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, serviceName).Return(nil) |
||||
}, |
||||
}, |
||||
} |
||||
for _, tt := range testCases { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
env := setupTestEnv(t) |
||||
if tt.init != nil { |
||||
tt.init(env) |
||||
} |
||||
|
||||
err := env.S.RemoveExternalService(context.Background(), serviceName) |
||||
require.NoError(t, err) |
||||
|
||||
env.OAuthStore.AssertExpectations(t) |
||||
env.SAService.AssertExpectations(t) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
keyOption *extsvcauth.KeyOption |
||||
expectedResult *extsvcauth.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: &extsvcauth.KeyOption{}, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should return successfully when PublicPEM is specified", |
||||
keyOption: &extsvcauth.KeyOption{ |
||||
PublicPEM: base64.StdEncoding.EncodeToString([]byte(`-----BEGIN PUBLIC KEY----- |
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ |
||||
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== |
||||
-----END PUBLIC KEY-----`)), |
||||
}, |
||||
wantErr: false, |
||||
expectedResult: &extsvcauth.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(), &extsvcauth.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(), &extsvcauth.KeyOption{Generate: true}) |
||||
|
||||
require.NoError(t, err) |
||||
require.NotNil(t, result.PrivatePem) |
||||
require.NotNil(t, result.PublicPem) |
||||
require.True(t, result.Generated) |
||||
}) |
||||
} |
||||
|
||||
func TestOAuth2ServiceImpl_handlePluginStateChanged(t *testing.T) { |
||||
pluginID := "my-app" |
||||
clientID := "RANDOMID" |
||||
impersonatePermission := []ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}} |
||||
selfPermission := append(impersonatePermission, ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}) |
||||
saID := int64(101) |
||||
client := &oauthserver.OAuthExternalService{ |
||||
ID: 11, |
||||
Name: pluginID, |
||||
ClientID: clientID, |
||||
Secret: "SECRET", |
||||
ServiceAccountID: saID, |
||||
} |
||||
clientWithImpersonate := &oauthserver.OAuthExternalService{ |
||||
ID: 11, |
||||
Name: pluginID, |
||||
ClientID: clientID, |
||||
Secret: "SECRET", |
||||
ImpersonatePermissions: []ac.Permission{ |
||||
{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}, |
||||
}, |
||||
ServiceAccountID: saID, |
||||
} |
||||
extSvcAcc := &sa.ExtSvcAccount{ |
||||
ID: saID, |
||||
Login: "sa-my-app", |
||||
Name: pluginID, |
||||
OrgID: extsvcauth.TmpOrgID, |
||||
IsDisabled: false, |
||||
Role: org.RoleNone, |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
init func(*TestEnv) |
||||
cmd *pluginsettings.PluginStateChangedEvent |
||||
}{ |
||||
{ |
||||
name: "should do nothing with not found", |
||||
init: func(te *TestEnv) { |
||||
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, "unknown").Return(nil, oauthserver.ErrClientNotFoundFn("unknown")) |
||||
}, |
||||
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: "unknown", OrgId: 1, Enabled: false}, |
||||
}, |
||||
{ |
||||
name: "should remove grants", |
||||
init: func(te *TestEnv) { |
||||
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, pluginID).Return(clientWithImpersonate, nil) |
||||
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, "").Return(nil) |
||||
}, |
||||
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: false}, |
||||
}, |
||||
{ |
||||
name: "should set both grants", |
||||
init: func(te *TestEnv) { |
||||
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil) |
||||
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) |
||||
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil) |
||||
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, |
||||
string(fosite.GrantTypeClientCredentials)+","+string(fosite.GrantTypeJWTBearer)).Return(nil) |
||||
}, |
||||
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, |
||||
}, |
||||
{ |
||||
name: "should set impersonate grant", |
||||
init: func(te *TestEnv) { |
||||
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil) |
||||
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) |
||||
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(impersonatePermission, nil) |
||||
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeJWTBearer)).Return(nil) |
||||
}, |
||||
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, |
||||
}, |
||||
{ |
||||
name: "should set client_credentials grant", |
||||
init: func(te *TestEnv) { |
||||
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(client, nil) |
||||
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil) |
||||
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil) |
||||
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeClientCredentials)).Return(nil) |
||||
}, |
||||
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
env := setupTestEnv(t) |
||||
if tt.init != nil { |
||||
tt.init(env) |
||||
} |
||||
|
||||
err := env.S.handlePluginStateChanged(context.Background(), tt.cmd) |
||||
require.NoError(t, err) |
||||
|
||||
// Check that mocks were called as expected
|
||||
env.OAuthStore.AssertExpectations(t) |
||||
env.SAService.AssertExpectations(t) |
||||
env.AcStore.AssertExpectations(t) |
||||
}) |
||||
} |
||||
} |
@ -1,16 +0,0 @@ |
||||
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 |
||||
} |
@ -1,353 +0,0 @@ |
||||
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/auth/identity" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/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.OAuthExternalService) 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{NamespacedID: fmt.Sprintf("%s:%d", identity.NamespaceUser, 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.OAuthExternalService) 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 |
||||
} |
@ -1,745 +0,0 @@ |
||||
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/extsvcauth/oauthserver" |
||||
sa "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.OAuthExternalService{ |
||||
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.OAuthExternalService |
||||
expectedClaims map[string]any |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "no claim without client_credentials grant type", |
||||
client: &oauthserver.OAuthExternalService{ |
||||
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]any{"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]any{"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]any{"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.OAuthExternalService{ |
||||
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.OAuthExternalService { |
||||
client := *client1 |
||||
client.ImpersonatePermissions = perms |
||||
return &client |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
initEnv func(*TestEnv) |
||||
scopes []string |
||||
client *oauthserver.OAuthExternalService |
||||
subject string |
||||
expectedClaims map[string]any |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "no claim without jwtbearer grant type", |
||||
client: &oauthserver.OAuthExternalService{ |
||||
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.OAuthExternalService{ |
||||
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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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]any{ |
||||
"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.OAuthExternalService) |
||||
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.OAuthExternalService) { |
||||
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.OAuthExternalService) { |
||||
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.OAuthExternalService) { |
||||
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.OAuthExternalService) { |
||||
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, resp.Body.String()) |
||||
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.OAuthExternalService)) { |
||||
now := time.Now() |
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte("CLIENT1SECRET"), bcrypt.DefaultCost) |
||||
require.NoError(t, err) |
||||
client1 := &oauthserver.OAuthExternalService{ |
||||
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 := &sa.ExtSvcAccount{ |
||||
ID: client1.ServiceAccountID, |
||||
Name: client1.Name, |
||||
Login: client1.Name, |
||||
OrgID: oauthserver.TmpOrgID, |
||||
IsDisabled: false, |
||||
Role: roletype.RoleNone, |
||||
} |
||||
|
||||
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("RetrieveExtSvcAccount", 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 |
||||
} |
@ -1,38 +0,0 @@ |
||||
package oastest |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"gopkg.in/square/go-jose.v2" |
||||
) |
||||
|
||||
type FakeService struct { |
||||
ExpectedClient *oauthserver.OAuthExternalService |
||||
ExpectedKey *jose.JSONWebKey |
||||
ExpectedErr error |
||||
} |
||||
|
||||
var _ oauthserver.OAuth2Server = &FakeService{} |
||||
|
||||
func (s *FakeService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { |
||||
return s.ExpectedClient.ToExternalService(nil), s.ExpectedErr |
||||
} |
||||
|
||||
func (s *FakeService) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { |
||||
return s.ExpectedClient, s.ExpectedErr |
||||
} |
||||
|
||||
func (s *FakeService) GetExternalServiceNames(ctx context.Context) ([]string, error) { |
||||
return nil, nil |
||||
} |
||||
|
||||
func (s *FakeService) RemoveExternalService(ctx context.Context, name string) error { |
||||
return s.ExpectedErr |
||||
} |
||||
|
||||
func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {} |
||||
|
||||
func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {} |
@ -1,191 +0,0 @@ |
||||
// Code generated by mockery v2.35.2. DO NOT EDIT.
|
||||
|
||||
package oastest |
||||
|
||||
import ( |
||||
context "context" |
||||
|
||||
mock "github.com/stretchr/testify/mock" |
||||
jose "gopkg.in/square/go-jose.v2" |
||||
|
||||
oauthserver "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
) |
||||
|
||||
// MockStore is an autogenerated mock type for the Store type
|
||||
type MockStore struct { |
||||
mock.Mock |
||||
} |
||||
|
||||
// DeleteExternalService provides a mock function with given fields: ctx, id
|
||||
func (_m *MockStore) DeleteExternalService(ctx context.Context, id string) error { |
||||
ret := _m.Called(ctx, id) |
||||
|
||||
var r0 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { |
||||
r0 = rf(ctx, id) |
||||
} else { |
||||
r0 = ret.Error(0) |
||||
} |
||||
|
||||
return r0 |
||||
} |
||||
|
||||
// GetExternalService provides a mock function with given fields: ctx, id
|
||||
func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { |
||||
ret := _m.Called(ctx, id) |
||||
|
||||
var r0 *oauthserver.OAuthExternalService |
||||
var r1 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok { |
||||
return rf(ctx, id) |
||||
} |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok { |
||||
r0 = rf(ctx, id) |
||||
} else { |
||||
if ret.Get(0) != nil { |
||||
r0 = ret.Get(0).(*oauthserver.OAuthExternalService) |
||||
} |
||||
} |
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { |
||||
r1 = rf(ctx, id) |
||||
} else { |
||||
r1 = ret.Error(1) |
||||
} |
||||
|
||||
return r0, r1 |
||||
} |
||||
|
||||
// GetExternalServiceByName provides a mock function with given fields: ctx, name
|
||||
func (_m *MockStore) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) { |
||||
ret := _m.Called(ctx, name) |
||||
|
||||
var r0 *oauthserver.OAuthExternalService |
||||
var r1 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok { |
||||
return rf(ctx, name) |
||||
} |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok { |
||||
r0 = rf(ctx, name) |
||||
} else { |
||||
if ret.Get(0) != nil { |
||||
r0 = ret.Get(0).(*oauthserver.OAuthExternalService) |
||||
} |
||||
} |
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { |
||||
r1 = rf(ctx, name) |
||||
} else { |
||||
r1 = ret.Error(1) |
||||
} |
||||
|
||||
return r0, r1 |
||||
} |
||||
|
||||
// GetExternalServiceNames provides a mock function with given fields: ctx
|
||||
func (_m *MockStore) GetExternalServiceNames(ctx context.Context) ([]string, error) { |
||||
ret := _m.Called(ctx) |
||||
|
||||
var r0 []string |
||||
var r1 error |
||||
if rf, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { |
||||
return rf(ctx) |
||||
} |
||||
if rf, ok := ret.Get(0).(func(context.Context) []string); ok { |
||||
r0 = rf(ctx) |
||||
} else { |
||||
if ret.Get(0) != nil { |
||||
r0 = ret.Get(0).([]string) |
||||
} |
||||
} |
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context) error); ok { |
||||
r1 = rf(ctx) |
||||
} else { |
||||
r1 = ret.Error(1) |
||||
} |
||||
|
||||
return r0, r1 |
||||
} |
||||
|
||||
// GetExternalServicePublicKey provides a mock function with given fields: ctx, clientID
|
||||
func (_m *MockStore) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) { |
||||
ret := _m.Called(ctx, clientID) |
||||
|
||||
var r0 *jose.JSONWebKey |
||||
var r1 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (*jose.JSONWebKey, error)); ok { |
||||
return rf(ctx, clientID) |
||||
} |
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *jose.JSONWebKey); ok { |
||||
r0 = rf(ctx, clientID) |
||||
} else { |
||||
if ret.Get(0) != nil { |
||||
r0 = ret.Get(0).(*jose.JSONWebKey) |
||||
} |
||||
} |
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { |
||||
r1 = rf(ctx, clientID) |
||||
} else { |
||||
r1 = ret.Error(1) |
||||
} |
||||
|
||||
return r0, r1 |
||||
} |
||||
|
||||
// RegisterExternalService provides a mock function with given fields: ctx, client
|
||||
func (_m *MockStore) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { |
||||
ret := _m.Called(ctx, client) |
||||
|
||||
var r0 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok { |
||||
r0 = rf(ctx, client) |
||||
} else { |
||||
r0 = ret.Error(0) |
||||
} |
||||
|
||||
return r0 |
||||
} |
||||
|
||||
// SaveExternalService provides a mock function with given fields: ctx, client
|
||||
func (_m *MockStore) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { |
||||
ret := _m.Called(ctx, client) |
||||
|
||||
var r0 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok { |
||||
r0 = rf(ctx, client) |
||||
} else { |
||||
r0 = ret.Error(0) |
||||
} |
||||
|
||||
return r0 |
||||
} |
||||
|
||||
// UpdateExternalServiceGrantTypes provides a mock function with given fields: ctx, clientID, grantTypes
|
||||
func (_m *MockStore) UpdateExternalServiceGrantTypes(ctx context.Context, clientID string, grantTypes string) error { |
||||
ret := _m.Called(ctx, clientID, grantTypes) |
||||
|
||||
var r0 error |
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { |
||||
r0 = rf(ctx, clientID, grantTypes) |
||||
} else { |
||||
r0 = ret.Error(0) |
||||
} |
||||
|
||||
return r0 |
||||
} |
||||
|
||||
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockStore(t interface { |
||||
mock.TestingT |
||||
Cleanup(func()) |
||||
}) *MockStore { |
||||
mock := &MockStore{} |
||||
mock.Mock.Test(t) |
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) }) |
||||
|
||||
return mock |
||||
} |
@ -1,252 +0,0 @@ |
||||
package store |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/ecdsa" |
||||
"crypto/rsa" |
||||
"errors" |
||||
|
||||
"gopkg.in/square/go-jose.v2" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils" |
||||
) |
||||
|
||||
type store struct { |
||||
db db.DB |
||||
} |
||||
|
||||
func NewStore(db db.DB) oauthserver.Store { |
||||
return &store{db: db} |
||||
} |
||||
|
||||
func createImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService) error { |
||||
if len(client.ImpersonatePermissions) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
insertPermQuery := make([]any, 1, len(client.ImpersonatePermissions)*3+1) |
||||
insertPermStmt := `INSERT INTO oauth_impersonate_permission (client_id, action, scope) VALUES ` |
||||
for _, perm := range client.ImpersonatePermissions { |
||||
insertPermStmt += "(?, ?, ?)," |
||||
insertPermQuery = append(insertPermQuery, client.ClientID, perm.Action, perm.Scope) |
||||
} |
||||
insertPermQuery[0] = insertPermStmt[:len(insertPermStmt)-1] |
||||
_, err := sess.Exec(insertPermQuery...) |
||||
return err |
||||
} |
||||
|
||||
func registerExternalService(sess *db.Session, client *oauthserver.OAuthExternalService) error { |
||||
insertQuery := []any{ |
||||
`INSERT INTO oauth_client (name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, |
||||
client.Name, |
||||
client.ClientID, |
||||
client.Secret, |
||||
client.GrantTypes, |
||||
client.Audiences, |
||||
client.ServiceAccountID, |
||||
client.PublicPem, |
||||
client.RedirectURI, |
||||
} |
||||
if _, err := sess.Exec(insertQuery...); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return createImpersonatePermissions(sess, client) |
||||
} |
||||
|
||||
func (s *store) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { |
||||
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
return registerExternalService(sess, client) |
||||
}) |
||||
} |
||||
|
||||
func recreateImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error { |
||||
deletePermQuery := `DELETE FROM oauth_impersonate_permission WHERE client_id = ?` |
||||
if _, errDelPerm := sess.Exec(deletePermQuery, prevClientID); errDelPerm != nil { |
||||
return errDelPerm |
||||
} |
||||
|
||||
if len(client.ImpersonatePermissions) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
return createImpersonatePermissions(sess, client) |
||||
} |
||||
|
||||
func updateExternalService(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error { |
||||
updateQuery := []any{ |
||||
`UPDATE oauth_client SET client_id = ?, secret = ?, grant_types = ?, audiences = ?, service_account_id = ?, public_pem = ?, redirect_uri = ? WHERE name = ?`, |
||||
client.ClientID, |
||||
client.Secret, |
||||
client.GrantTypes, |
||||
client.Audiences, |
||||
client.ServiceAccountID, |
||||
client.PublicPem, |
||||
client.RedirectURI, |
||||
client.Name, |
||||
} |
||||
if _, err := sess.Exec(updateQuery...); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return recreateImpersonatePermissions(sess, client, prevClientID) |
||||
} |
||||
|
||||
func (s *store) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error { |
||||
if client.Name == "" { |
||||
return oauthserver.ErrClientRequiredName |
||||
} |
||||
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
previous, errFetchExtSvc := getExternalServiceByName(sess, client.Name) |
||||
if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) { |
||||
return errFetchExtSvc |
||||
} |
||||
if previous == nil { |
||||
return registerExternalService(sess, client) |
||||
} |
||||
return updateExternalService(sess, client, previous.ClientID) |
||||
}) |
||||
} |
||||
|
||||
func (s *store) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { |
||||
res := &oauthserver.OAuthExternalService{} |
||||
if id == "" { |
||||
return nil, oauthserver.ErrClientRequiredID |
||||
} |
||||
|
||||
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
getClientQuery := `SELECT |
||||
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri |
||||
FROM oauth_client |
||||
WHERE client_id = ?` |
||||
found, err := sess.SQL(getClientQuery, id).Get(res) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if !found { |
||||
res = nil |
||||
return oauthserver.ErrClientNotFoundFn(id) |
||||
} |
||||
|
||||
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?` |
||||
return sess.SQL(impersonatePermQuery, id).Find(&res.ImpersonatePermissions) |
||||
}) |
||||
|
||||
return res, err |
||||
} |
||||
|
||||
// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check
|
||||
// signature of jwt assertion in authorization grants.
|
||||
func (s *store) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) { |
||||
res := &oauthserver.OAuthExternalService{} |
||||
if clientID == "" { |
||||
return nil, oauthserver.ErrClientRequiredID |
||||
} |
||||
|
||||
if err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
getKeyQuery := `SELECT public_pem FROM oauth_client WHERE client_id = ?` |
||||
found, err := sess.SQL(getKeyQuery, clientID).Get(res) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if !found { |
||||
return oauthserver.ErrClientNotFoundFn(clientID) |
||||
} |
||||
return nil |
||||
}); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
key, errParseKey := utils.ParsePublicKeyPem(res.PublicPem) |
||||
if errParseKey != nil { |
||||
return nil, errParseKey |
||||
} |
||||
|
||||
var alg string |
||||
switch key.(type) { |
||||
case *rsa.PublicKey: |
||||
alg = oauthserver.RS256 |
||||
case *ecdsa.PublicKey: |
||||
alg = oauthserver.ES256 |
||||
} |
||||
|
||||
return &jose.JSONWebKey{ |
||||
Algorithm: alg, |
||||
Key: key, |
||||
}, nil |
||||
} |
||||
|
||||
func (s *store) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) { |
||||
res := &oauthserver.OAuthExternalService{} |
||||
if name == "" { |
||||
return nil, oauthserver.ErrClientRequiredName |
||||
} |
||||
|
||||
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
var errGetByName error |
||||
res, errGetByName = getExternalServiceByName(sess, name) |
||||
return errGetByName |
||||
}) |
||||
|
||||
return res, err |
||||
} |
||||
|
||||
func getExternalServiceByName(sess *db.Session, name string) (*oauthserver.OAuthExternalService, error) { |
||||
res := &oauthserver.OAuthExternalService{} |
||||
getClientQuery := `SELECT |
||||
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri |
||||
FROM oauth_client |
||||
WHERE name = ?` |
||||
found, err := sess.SQL(getClientQuery, name).Get(res) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if !found { |
||||
return nil, oauthserver.ErrClientNotFoundFn(name) |
||||
} |
||||
|
||||
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?` |
||||
errPerm := sess.SQL(impersonatePermQuery, res.ClientID).Find(&res.ImpersonatePermissions) |
||||
|
||||
return res, errPerm |
||||
} |
||||
|
||||
// FIXME: If we ever do a search method remove that method
|
||||
func (s *store) GetExternalServiceNames(ctx context.Context) ([]string, error) { |
||||
res := []string{} |
||||
|
||||
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
return sess.SQL(`SELECT name FROM oauth_client`).Find(&res) |
||||
}) |
||||
|
||||
return res, err |
||||
} |
||||
|
||||
func (s *store) UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error { |
||||
if clientID == "" { |
||||
return oauthserver.ErrClientRequiredID |
||||
} |
||||
|
||||
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
query := `UPDATE oauth_client SET grant_types = ? WHERE client_id = ?` |
||||
_, err := sess.Exec(query, grantTypes, clientID) |
||||
return err |
||||
}) |
||||
} |
||||
|
||||
func (s *store) DeleteExternalService(ctx context.Context, id string) error { |
||||
if id == "" { |
||||
return oauthserver.ErrClientRequiredID |
||||
} |
||||
|
||||
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { |
||||
if _, err := sess.Exec(`DELETE FROM oauth_client WHERE client_id = ?`, id); err != nil { |
||||
return err |
||||
} |
||||
|
||||
_, err := sess.Exec(`DELETE FROM oauth_impersonate_permission WHERE client_id = ?`, id) |
||||
return err |
||||
}) |
||||
} |
@ -1,490 +0,0 @@ |
||||
package store |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"testing" |
||||
|
||||
"github.com/go-jose/go-jose/v3" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/tests/testsuite" |
||||
) |
||||
|
||||
func TestMain(m *testing.M) { |
||||
testsuite.Run(m) |
||||
} |
||||
|
||||
func TestStore_RegisterAndGetClient(t *testing.T) { |
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
tests := []struct { |
||||
name string |
||||
client oauthserver.OAuthExternalService |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "register and get", |
||||
client: oauthserver.OAuthExternalService{ |
||||
Name: "The Worst App Ever", |
||||
ClientID: "ANonRandomClientID", |
||||
Secret: "ICouldKeepSecrets", |
||||
GrantTypes: "clients_credentials", |
||||
PublicPem: []byte(`------BEGIN FAKE PUBLIC KEY----- |
||||
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO |
||||
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB |
||||
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp |
||||
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg |
||||
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl |
||||
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ |
||||
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g |
||||
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g |
||||
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO |
||||
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB |
||||
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp |
||||
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg |
||||
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl |
||||
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ |
||||
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g |
||||
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g |
||||
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO |
||||
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB |
||||
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp |
||||
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4uLi4gSXQgSXMgSnVz |
||||
dCBBIFJlZ3VsYXIgQmFzZTY0IEVuY29kZWQgU3RyaW5nLi4uCg== |
||||
------END FAKE PUBLIC KEY-----`), |
||||
ServiceAccountID: 2, |
||||
SelfPermissions: nil, |
||||
ImpersonatePermissions: nil, |
||||
RedirectURI: "/whereto", |
||||
}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "register with impersonate permissions and get", |
||||
client: oauthserver.OAuthExternalService{ |
||||
Name: "The Best App Ever", |
||||
ClientID: "AnAlmostRandomClientID", |
||||
Secret: "ICannotKeepSecrets", |
||||
GrantTypes: "clients_credentials", |
||||
PublicPem: []byte(`test`), |
||||
ServiceAccountID: 2, |
||||
SelfPermissions: nil, |
||||
ImpersonatePermissions: []accesscontrol.Permission{ |
||||
{Action: "dashboards:create", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "dashboards:*"}, |
||||
{Action: "dashboards:write", Scope: "folders:*"}, |
||||
{Action: "dashboards:write", Scope: "dashboards:*"}, |
||||
}, |
||||
RedirectURI: "/whereto", |
||||
}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "register with audiences and get", |
||||
client: oauthserver.OAuthExternalService{ |
||||
Name: "The Most Normal App Ever", |
||||
ClientID: "AnAlmostRandomClientIDAgain", |
||||
Secret: "ICanKeepSecretsEventually", |
||||
GrantTypes: "clients_credentials", |
||||
PublicPem: []byte(`test`), |
||||
ServiceAccountID: 2, |
||||
SelfPermissions: nil, |
||||
Audiences: "https://oauth.test/,https://sub.oauth.test/", |
||||
RedirectURI: "/whereto", |
||||
}, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
ctx := context.Background() |
||||
err := s.RegisterExternalService(ctx, &tt.client) |
||||
if tt.wantErr { |
||||
require.Error(t, err) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
// Compare results
|
||||
compareClientToStored(t, s, &tt.client) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestStore_SaveExternalService(t *testing.T) { |
||||
client1 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service", |
||||
ClientID: "ClientID", |
||||
Secret: "Secret", |
||||
GrantTypes: "client_credentials", |
||||
PublicPem: []byte("test"), |
||||
ServiceAccountID: 2, |
||||
ImpersonatePermissions: []accesscontrol.Permission{}, |
||||
RedirectURI: "/whereto", |
||||
} |
||||
client1WithPerm := client1 |
||||
client1WithPerm.ImpersonatePermissions = []accesscontrol.Permission{ |
||||
{Action: "dashboards:read", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "dashboards:*"}, |
||||
} |
||||
client1WithNewSecrets := client1 |
||||
client1WithNewSecrets.ClientID = "NewClientID" |
||||
client1WithNewSecrets.Secret = "NewSecret" |
||||
client1WithNewSecrets.PublicPem = []byte("newtest") |
||||
|
||||
client1WithAud := client1 |
||||
client1WithAud.Audiences = "https://oauth.test/,https://sub.oauth.test/" |
||||
|
||||
tests := []struct { |
||||
name string |
||||
runs []oauthserver.OAuthExternalService |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "error no name", |
||||
runs: []oauthserver.OAuthExternalService{{}}, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "simple register", |
||||
runs: []oauthserver.OAuthExternalService{client1}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "no update", |
||||
runs: []oauthserver.OAuthExternalService{client1, client1}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "add permissions", |
||||
runs: []oauthserver.OAuthExternalService{client1, client1WithPerm}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "remove permissions", |
||||
runs: []oauthserver.OAuthExternalService{client1WithPerm, client1}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "update id and secrets", |
||||
runs: []oauthserver.OAuthExternalService{client1, client1WithNewSecrets}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "update audience", |
||||
runs: []oauthserver.OAuthExternalService{client1, client1WithAud}, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
for i := range tt.runs { |
||||
err := s.SaveExternalService(context.Background(), &tt.runs[i]) |
||||
if tt.wantErr { |
||||
require.Error(t, err) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
compareClientToStored(t, s, &tt.runs[i]) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestStore_GetExternalServiceByName(t *testing.T) { |
||||
client1 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service", |
||||
ClientID: "ClientID", |
||||
Secret: "Secret", |
||||
GrantTypes: "client_credentials", |
||||
PublicPem: []byte("test"), |
||||
ServiceAccountID: 2, |
||||
ImpersonatePermissions: []accesscontrol.Permission{}, |
||||
RedirectURI: "/whereto", |
||||
} |
||||
client2 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service-2", |
||||
ClientID: "ClientID2", |
||||
Secret: "Secret2", |
||||
GrantTypes: "client_credentials,urn:ietf:params:grant-type:jwt-bearer", |
||||
PublicPem: []byte("test2"), |
||||
ServiceAccountID: 3, |
||||
Audiences: "https://oauth.test/,https://sub.oauth.test/", |
||||
ImpersonatePermissions: []accesscontrol.Permission{ |
||||
{Action: "dashboards:read", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "dashboards:*"}, |
||||
}, |
||||
RedirectURI: "/whereto", |
||||
} |
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client1)) |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client2)) |
||||
|
||||
tests := []struct { |
||||
name string |
||||
search string |
||||
want *oauthserver.OAuthExternalService |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "no name provided", |
||||
search: "", |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "not found", |
||||
search: "unknown-external-service", |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "search client 1 by name", |
||||
search: "my-external-service", |
||||
want: &client1, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "search client 2 by name", |
||||
search: "my-external-service-2", |
||||
want: &client2, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
stored, err := s.GetExternalServiceByName(context.Background(), tt.search) |
||||
if tt.wantErr { |
||||
require.Error(t, err) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
compareClients(t, stored, tt.want) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestStore_GetExternalServicePublicKey(t *testing.T) { |
||||
clientID := "ClientID" |
||||
createClient := func(clientID string, publicPem string) *oauthserver.OAuthExternalService { |
||||
return &oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service", |
||||
ClientID: clientID, |
||||
Secret: "Secret", |
||||
GrantTypes: "client_credentials", |
||||
PublicPem: []byte(publicPem), |
||||
ServiceAccountID: 2, |
||||
ImpersonatePermissions: []accesscontrol.Permission{}, |
||||
RedirectURI: "/whereto", |
||||
} |
||||
} |
||||
|
||||
testCases := []struct { |
||||
name string |
||||
client *oauthserver.OAuthExternalService |
||||
clientID string |
||||
want *jose.JSONWebKey |
||||
wantKeyType string |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "should return an error when clientID is empty", |
||||
clientID: "", |
||||
client: createClient(clientID, ""), |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should return an error when the client was not found", |
||||
clientID: "random", |
||||
client: createClient(clientID, ""), |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should return an error when PublicPem is not valid", |
||||
clientID: clientID, |
||||
client: createClient(clientID, ""), |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should return the JSON Web Key ES256", |
||||
clientID: clientID, |
||||
client: createClient(clientID, `-----BEGIN PUBLIC KEY----- |
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ |
||||
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== |
||||
-----END PUBLIC KEY-----`), |
||||
wantKeyType: oauthserver.ES256, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "should return the JSON Web Key RS256", |
||||
clientID: clientID, |
||||
client: createClient(clientID, `-----BEGIN RSA PUBLIC KEY----- |
||||
MIIBCgKCAQEAxkly/cHvsxd6EcShGUlFAB5lIMlIbGRocCVWbIM26f6pnGr+gCNv |
||||
s365DQdQ/jUjF8bSEQM+EtjGlv2Y7Jm7dQROpPzX/1M+53Us/Gl138UtAEgL5ZKe |
||||
SKN5J/f9Nx4wkgb99v2Bt0nz6xv+kSJwgR0o8zi8shDR5n7a5mTdlQe2NOixzWlT |
||||
vnpp6Tm+IE+XyXXcrCr01I9Rf+dKuYOPSJ1K3PDgFmmGvsLcjRCCK9EftfY0keU+ |
||||
IP+sh8ewNxc6KcaLBXm3Tadb1c/HyuMi6FyYw7s9m8tyAvI1CMBAcXqLIEaRgNrc |
||||
vuO8AU0bVoUmYMKhozkcCYHudkeS08hEjQIDAQAB |
||||
-----END RSA PUBLIC KEY-----`), |
||||
wantKeyType: oauthserver.RS256, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
require.NoError(t, s.SaveExternalService(context.Background(), tc.client)) |
||||
|
||||
webKey, err := s.GetExternalServicePublicKey(context.Background(), tc.clientID) |
||||
if tc.wantErr { |
||||
require.Error(t, err) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
require.Equal(t, tc.wantKeyType, webKey.Algorithm) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestStore_RemoveExternalService(t *testing.T) { |
||||
ctx := context.Background() |
||||
client1 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service", |
||||
ClientID: "ClientID", |
||||
ImpersonatePermissions: []accesscontrol.Permission{}, |
||||
} |
||||
client2 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service-2", |
||||
ClientID: "ClientID2", |
||||
ImpersonatePermissions: []accesscontrol.Permission{ |
||||
{Action: "dashboards:read", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "dashboards:*"}, |
||||
}, |
||||
} |
||||
|
||||
// Init store
|
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client1)) |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client2)) |
||||
|
||||
// Check presence of clients in store
|
||||
getState := func(t *testing.T) map[string]bool { |
||||
client, err := s.GetExternalService(ctx, "ClientID") |
||||
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) { |
||||
require.Fail(t, "error fetching client") |
||||
} |
||||
|
||||
client2, err := s.GetExternalService(ctx, "ClientID2") |
||||
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) { |
||||
require.Fail(t, "error fetching client") |
||||
} |
||||
|
||||
return map[string]bool{ |
||||
"ClientID": client != nil, |
||||
"ClientID2": client2 != nil, |
||||
} |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
id string |
||||
state map[string]bool |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "no id provided", |
||||
state: map[string]bool{"ClientID": true, "ClientID2": true}, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "not found", |
||||
id: "ClientID3", |
||||
state: map[string]bool{"ClientID": true, "ClientID2": true}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "remove client 2", |
||||
id: "ClientID2", |
||||
state: map[string]bool{"ClientID": true, "ClientID2": false}, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "remove client 1", |
||||
id: "ClientID", |
||||
state: map[string]bool{"ClientID": false, "ClientID2": false}, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
err := s.DeleteExternalService(ctx, tt.id) |
||||
if tt.wantErr { |
||||
require.Error(t, err) |
||||
} else { |
||||
require.NoError(t, err) |
||||
} |
||||
require.EqualValues(t, tt.state, getState(t)) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func Test_store_GetExternalServiceNames(t *testing.T) { |
||||
ctx := context.Background() |
||||
client1 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service", |
||||
ClientID: "ClientID", |
||||
ImpersonatePermissions: []accesscontrol.Permission{}, |
||||
} |
||||
client2 := oauthserver.OAuthExternalService{ |
||||
Name: "my-external-service-2", |
||||
ClientID: "ClientID2", |
||||
ImpersonatePermissions: []accesscontrol.Permission{ |
||||
{Action: "dashboards:read", Scope: "folders:*"}, |
||||
{Action: "dashboards:read", Scope: "dashboards:*"}, |
||||
}, |
||||
} |
||||
|
||||
// Init store
|
||||
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})} |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client1)) |
||||
require.NoError(t, s.SaveExternalService(context.Background(), &client2)) |
||||
|
||||
got, err := s.GetExternalServiceNames(ctx) |
||||
require.NoError(t, err) |
||||
require.ElementsMatch(t, []string{"my-external-service", "my-external-service-2"}, got) |
||||
} |
||||
|
||||
func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.OAuthExternalService) { |
||||
ctx := context.Background() |
||||
stored, err := s.GetExternalService(ctx, wanted.ClientID) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, stored) |
||||
|
||||
compareClients(t, stored, wanted) |
||||
} |
||||
|
||||
func compareClients(t *testing.T, stored *oauthserver.OAuthExternalService, wanted *oauthserver.OAuthExternalService) { |
||||
// Reset ID so we can compare
|
||||
require.NotZero(t, stored.ID) |
||||
stored.ID = 0 |
||||
|
||||
// Compare permissions separately
|
||||
wantedPerms := wanted.ImpersonatePermissions |
||||
storedPerms := stored.ImpersonatePermissions |
||||
wanted.ImpersonatePermissions = nil |
||||
stored.ImpersonatePermissions = nil |
||||
require.EqualValues(t, *wanted, *stored) |
||||
require.ElementsMatch(t, wantedPerms, storedPerms) |
||||
} |
@ -1,35 +0,0 @@ |
||||
package utils |
||||
|
||||
import ( |
||||
"crypto/x509" |
||||
"encoding/pem" |
||||
"errors" |
||||
"fmt" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn" |
||||
) |
||||
|
||||
// ParseUserIDFromSubject parses the user ID from format "user:id:<id>".
|
||||
func ParseUserIDFromSubject(subject string) (int64, error) { |
||||
trimmed := strings.TrimPrefix(subject, fmt.Sprintf("%s:id:", authn.NamespaceUser)) |
||||
return strconv.ParseInt(trimmed, 10, 64) |
||||
} |
||||
|
||||
// ParsePublicKeyPem parses the public key from the PEM encoded public key.
|
||||
func ParsePublicKeyPem(publicPem []byte) (any, error) { |
||||
block, _ := pem.Decode(publicPem) |
||||
if block == nil { |
||||
return nil, errors.New("could not decode PEM block") |
||||
} |
||||
|
||||
switch block.Type { |
||||
case "PUBLIC KEY": |
||||
return x509.ParsePKIXPublicKey(block.Bytes) |
||||
case "RSA PUBLIC KEY": |
||||
return x509.ParsePKCS1PublicKey(block.Bytes) |
||||
default: |
||||
return nil, fmt.Errorf("unknown key type %q", block.Type) |
||||
} |
||||
} |
@ -1,82 +0,0 @@ |
||||
package utils |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestParsePublicKeyPem(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
publicKeyPem string |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "should return error when the public key pem is empty", |
||||
publicKeyPem: "", |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should return error when the public key pem is invalid", |
||||
publicKeyPem: `-----BEGIN RSA PRIVATE KEY----- |
||||
MIIEowIBAAKCAQEAxP72NEnQF3o3eFFMtFqyloW9oLhTydxXS2dA2NolMvXewO77 |
||||
UvJw54wkOdrJrJO2BIw+XBrrb+13+koRUnwa2DNsh+SWG0PEe/31mt0zJrCmNM37 |
||||
iJYIu3KZR2aRlierVY5gyrIniBIZ9blQspI6SRY9xmo6Wdh0VCRnsCV5sMlaqerI |
||||
snLpYOjGtMmL0rFuW2jKrAzpbq7L99IDgPbiH7tluaQkGIxoc29S4wjwg0NgQONT |
||||
GkfJEeXQIkxOHNM5WGb8mvjX4U0jMdXvC4WUWcS+KpcIV7ee8uEs2xDz++N4HYAS |
||||
ty37sY8wwW22QUW9I7XlSC4rsC88Ft5ar8yLsQIDAQABAoIBAAQ1yTv+mFmKGYGj |
||||
JiskFZVBNDdpPRQvNvfj8+c2iU08ozc3HEyuZQKT1InefsknCoCwIRyNkDrPBc2F |
||||
8/cR8y5r8e25EUqxoPM/7xXxVIinBZRTEyU9BKCB71vu6Z1eiWs9jNzEIDNopKCj |
||||
ZmG8nY2Gkckp58eYCEtskEE72c0RBPg8ZTBdc1cLqbNVUjkLvR5e98ruDz6b+wyH |
||||
FnztZ0k48zM047Ior69OwFRBg+S7d6cgMMmcq4X2pg3xgQMs0Se/4+pmvBf9JPSB |
||||
kl3qpVAkzM1IFdrmpFtBzeaqYNj3uU6Bm7NxEiqjAoeDxO231ziSdzIPtXIy5eRl |
||||
9WMZCqkCgYEA1ZOaT77aa54zgjAwjNB2Poo3yoUtYJz+yNCR0CPM4MzCas3PR4XI |
||||
WUXo+RNofWvRJF88aAVX7+J0UTnRr25rN12NDbo3aZhX2YNDGBe3hgB/FOAI5UAh |
||||
9SaU070PFeGzqlu/xWdx5GFk/kiNUNLX/X4xgUGPTiwY4LQeI9lffzkCgYEA7CA7 |
||||
VHaNPGVsaNKMJVjrZeYOxNBsrH99IEgaP76DC+EVR2JYVzrNxmN6ZlRxD4CRTcyd |
||||
oquTFoFFw26gJIJAYF8MtusOD3PArnpdCRSoELezYdtVhS0yx8TSHGVC9MWSSt7O |
||||
IdjzEFpA99HPkYFjXUiWXjfCTK7ofI0RXC6a+DkCgYEAoQb8nYuEGwfYRhwXPtQd |
||||
kuGbVvI6WFGGN9opVgjn+8Xl/6jU01QmzkhLcyAS9B1KPmYfoT4GIzNWB7fURLS3 |
||||
2bKLGwJ/rPnTooe5Gn0nPb06E38mtdI4yCEirNIqgZD+aT9rw2ZPFKXqA16oTXvq |
||||
pZFzucS4S3Qr/Z9P6i+GNOECgYBkvPuS9WEcO0kdD3arGFyVhKkYXrN+hIWlmB1a |
||||
xLS0BLtHUTXPQU85LIez0KLLslZLkthN5lVCbLSOxEueR9OfSe3qvC2ref7icWHv |
||||
1dg+CaGGRkUeJEJd6CKb6re+Jexb9OKMnjpU56yADgs4ULNLwQQl/jPu81BMkwKt |
||||
CVUkQQKBgFvbuUmYtP3aqV/Kt036Q6aB6Xwg29u2XFTe4BgW7c55teebtVmGA/zc |
||||
GMwRsF4rWCcScmHGcSKlW9L6S6OxmkYjDDRhimKyHgoiQ9tawWag2XCeOlyJ+hkc |
||||
/qwwKxScuFIi2xwT+aAmR70Xk11qXTft+DaEcHdxOOZD8gA0Gxr3 |
||||
-----END RSA PRIVATE KEY-----`, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "should parse the public key if it is in PKCS1 format", |
||||
publicKeyPem: `-----BEGIN RSA PUBLIC KEY----- |
||||
MIIBCgKCAQEAy06MeS06Ea7zGKfOM8kosxuUBMNhrWKWMvW4Jq1IXG+lyTfann2+ |
||||
kI1rKeWAQ9YbxNzLynahoKN47EQ6mqM1Yj5v9iKWtSvCMKHWBuqrG5ksaEQaAVsA |
||||
PDg8aOQrI1MSW9Hoc1CummcWX+HKNPVwIzG3sCboENFzEG8GrJgoNHZgmyOYEMMD |
||||
2WCdfY0I9Dm0/uuNMAcyMuVhRhOtT3j91zCXvDju2+M2EejApMkV9r7FqGmNH5Hw |
||||
8u43nWXnWc4UYXEItE8nPxuqsZia2mdB5MSIdKu8a7ytFcQ+tiK6vempnxHZytEL |
||||
6NDX8DLydHbEsLUn6hc76ODVkr/wRiuYdQIDAQAB |
||||
-----END RSA PUBLIC KEY-----`, |
||||
wantErr: false, |
||||
}, |
||||
{ |
||||
name: "should parse the public key if it is in PKIX/X.509 format", |
||||
publicKeyPem: `-----BEGIN PUBLIC KEY----- |
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+ |
||||
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw== |
||||
-----END PUBLIC KEY-----`, |
||||
wantErr: false, |
||||
}, |
||||
} |
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
_, err := ParsePublicKeyPem([]byte(tc.publicKeyPem)) |
||||
if tc.wantErr { |
||||
require.Error(t, err) |
||||
} else { |
||||
require.NoError(t, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
|
@ -1,52 +0,0 @@ |
||||
package oauthserver |
||||
|
||||
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" |
||||
|
||||
func AddMigration(mg *migrator.Migrator) { |
||||
impersonatePermissionsTable := migrator.Table{ |
||||
Name: "oauth_impersonate_permission", |
||||
Columns: []*migrator.Column{ |
||||
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, |
||||
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, |
||||
{Name: "action", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, |
||||
{Name: "scope", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, |
||||
}, |
||||
Indices: []*migrator.Index{ |
||||
{Cols: []string{"client_id", "action", "scope"}, Type: migrator.UniqueIndex}, |
||||
}, |
||||
} |
||||
|
||||
clientTable := migrator.Table{ |
||||
Name: "oauth_client", |
||||
Columns: []*migrator.Column{ |
||||
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, |
||||
{Name: "name", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, |
||||
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, |
||||
{Name: "secret", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, |
||||
{Name: "grant_types", Type: migrator.DB_Text, Nullable: true}, |
||||
{Name: "audiences", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, |
||||
{Name: "service_account_id", Type: migrator.DB_BigInt, Nullable: true}, |
||||
{Name: "public_pem", Type: migrator.DB_Text, Nullable: true}, |
||||
{Name: "redirect_uri", Type: migrator.DB_Varchar, Length: 190, Nullable: true}, |
||||
}, |
||||
Indices: []*migrator.Index{ |
||||
{Cols: []string{"client_id"}, Type: migrator.UniqueIndex}, |
||||
{Cols: []string{"client_id", "service_account_id"}, Type: migrator.UniqueIndex}, |
||||
{Cols: []string{"name"}, Type: migrator.UniqueIndex}, |
||||
}, |
||||
} |
||||
|
||||
// Impersonate Permission
|
||||
mg.AddMigration("create impersonate permissions table", migrator.NewAddTableMigration(impersonatePermissionsTable)) |
||||
|
||||
//------- indexes ------------------
|
||||
mg.AddMigration("add unique index client_id action scope", migrator.NewAddIndexMigration(impersonatePermissionsTable, impersonatePermissionsTable.Indices[0])) |
||||
|
||||
// Client
|
||||
mg.AddMigration("create client table", migrator.NewAddTableMigration(clientTable)) |
||||
|
||||
//------- indexes ------------------
|
||||
mg.AddMigration("add unique index client_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[0])) |
||||
mg.AddMigration("add unique index client_id service_account_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[1])) |
||||
mg.AddMigration("add unique index name", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[2])) |
||||
} |
Loading…
Reference in new issue