grafana/pkg/services/oauthserver/oasimpl/token.go
Gabriel MABILLE edf1775d49
AuthN: Embed an OAuth2 server for external service authentication (#68086)
* Moving POC files from #64283 to a new branch

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

* Adding missing permission definition

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

* Force the service instantiation while client isn't merged

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

* Merge conf with main

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

* Leave go-sqlite3 version unchanged

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

* tidy

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

* User SearchUserPermissions instead of SearchUsersPermissions

* Replace DummyKeyService with signingkeys.Service

* Use user🆔<id> as subject

* Fix introspection endpoint issue

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

* Regenerate toggles_gen.go
* Fix basic.go

* Add GetExternalService tests

* Add GetPublicKeyScopes tests

* Add GetScopesOnUser tests

* Add GetScopes tests

* Add ParsePublicKeyPem tests

* Add database test for GetByName

* re-add comments

* client tests added

* Add GetExternalServicePublicKey tests

* Add other test case to GetExternalServicePublicKey

* client_credentials grant test

* Add test to jwtbearer grant

* Test Comments

* Add handleKeyOptions tests

* Add RSA key generation test

* Add ECDSA by default to EmbeddedSigningKeysService

* Clean up org id scope and audiences

* Add audiences to the DB

* Fix check on Audience

* Fix double import

* Add AC Store mock and align oauthserver tests

* Fix test after rebase

* Adding missing store function to mock

* Fix double import

* Add CODEOWNER

* Fix some linting errors

* errors don't need type assertion

* Typo codeowners

* use mockery for oauthserver store

* Add feature toggle check

* Fix db tests to handle the feature flag

* Adding call to DeleteExternalServiceRole

* Fix flaky test

* Re-organize routes comments and plan futur work

* Add client_id check to Extended JWT client

* Clean up

* Fix

* Remove background service registry instantiation of the OAuth server

* Comment cleanup

* Remove unused client function

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

* Remove oauth2_server related configs from defaults.ini

* Add audiences to DTO

* Fix flaky test

* Remove registration endpoint and demo scripts. Document code

* Rename packages

* Remove the OAuthService vs OAuthServer confusion

* fix incorrect import ext_jwt_test

* Comments and order

* Comment basic auth

* Remove unecessary todo

* Clean api

* Moving ParsePublicKeyPem to utils

* re ordering functions in service.go

* Fix comment

* comment on the redirect uri

* Add RBAC actions, not only scopes

* Fix tests

* re-import featuremgmt in migrations

* Fix wire

* Fix scopes in test

* Fix flaky test

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

* Remove unecessary check from intersection code

* Allow env overrides on settings

* remove the term app name

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

* LogID remove ExternalService ref

* Use Name instead of ExternalServiceName

* Imports order

* Inline

* Using ExternalService and ExternalServiceDTO

* Remove xorm tags

* comment

* Rename client files

* client -> external service

* comments

* Move test to correct package

* slimmer test

* cachedUser -> cachedExternalService

* Fix aggregate store test

* PluginAuthSession -> AuthSession

* Revert the nil cehcks

* Remove unecessary extra

* Removing custom session

* fix typo in test

* Use constants for tests

* Simplify HandleToken tests

* Refactor the HandleTokenRequest test

* test message

* Review test

* Prevent flacky test on client as well

* go imports

* Revert changes from 526e48ad45

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

* AuthN: change the External Service registration form

* Gen default permissions

* Change demo script registration form

* Remove unecessary comment

* Nit.

* Reduce cyclomatic complexity

* Remove demo_scripts

* Handle case with no service account

* Comments

* Group key gen

* Nit.

* Check the SaveExternalService test

* Rename cachedUser to cachedClient in test

* One more test case to database test

* Comments

* Remove last org scope

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

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

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

Remove comment

* Update pkg/setting/setting.go

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

---------

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
2023-05-25 15:38:30 +02:00

352 lines
12 KiB
Go

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