Skip to content

Commit

Permalink
Adds gsuite provider specific extension for fetching groups and user …
Browse files Browse the repository at this point in the history
…information (#123) (#133)

Co-authored-by: Austin Gebauer <[email protected]>
  • Loading branch information
calvn and austingebauer authored Aug 17, 2020
1 parent 8a9339e commit 9523642
Show file tree
Hide file tree
Showing 437 changed files with 80,682 additions and 88,340 deletions.
9 changes: 4 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ require (
github.com/hashicorp/vault/api v1.0.5-0.20200215224050-f6547fa8e820
github.com/hashicorp/vault/sdk v0.1.14-0.20200215224050-f6547fa8e820
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect
github.com/mitchellh/mapstructure v1.1.2
github.com/mitchellh/pointerstructure v1.0.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/ryanuber/go-glob v1.0.0
github.com/stretchr/testify v1.3.0
github.com/stretchr/testify v1.4.0
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sync v0.0.0-20190423024810-112230192c58
golang.org/x/text v0.3.2 // indirect
google.golang.org/appengine v1.5.0 // indirect
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64 // indirect
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
google.golang.org/api v0.29.0
gopkg.in/square/go-jose.v2 v2.4.1
)
238 changes: 238 additions & 0 deletions go.sum

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ func (b *jwtAuthBackend) createIdentity(allClaims map[string]interface{}, role *
return nil, nil, fmt.Errorf("claim %q could not be converted to string", role.UserClaim)
}

err := b.fetchUserInfo(allClaims, role)
if err != nil {
return nil, nil, err
}

metadata, err := extractMetadata(b.Logger(), allClaims, role.ClaimMappings)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -360,6 +365,22 @@ func (b *jwtAuthBackend) createIdentity(allClaims map[string]interface{}, role *
return alias, groupAliases, nil
}

// Checks if there's a custom provider_config and calls FetchUserInfo() if implemented.
func (b *jwtAuthBackend) fetchUserInfo(allClaims map[string]interface{}, role *jwtRole) error {
pConfig, err := NewProviderConfig(b.cachedConfig, ProviderMap())
if err != nil {
return fmt.Errorf("failed to load custom provider config: %s", err)
}
// Fetch user info from custom provider if it's implemented
if pConfig != nil {
if uif, ok := pConfig.(UserInfoFetcher); ok {
return uif.FetchUserInfo(b, allClaims, role)
}
}

return nil
}

// Checks if there's a custom provider_config and calls FetchGroups() if implemented
func (b *jwtAuthBackend) fetchGroups(allClaims map[string]interface{}, role *jwtRole) (interface{}, error) {
pConfig, err := NewProviderConfig(b.cachedConfig, ProviderMap())
Expand Down
8 changes: 7 additions & 1 deletion provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import (
// ProviderMap returns a map of provider names to custom types
func ProviderMap() map[string]CustomProvider {
return map[string]CustomProvider{
"azure": &AzureProvider{},
"azure": &AzureProvider{},
"gsuite": &GSuiteProvider{},
}
}

Expand Down Expand Up @@ -48,6 +49,11 @@ func NewProviderConfig(jc *jwtConfig, providerMap map[string]CustomProvider) (Cu
return newCustomProvider, nil
}

// UserInfoFetcher - Optional support for custom user info handling
type UserInfoFetcher interface {
FetchUserInfo(*jwtAuthBackend, map[string]interface{}, *jwtRole) error
}

// GroupsFetcher - Optional support for custom groups handling
type GroupsFetcher interface {
// FetchGroups queries for groups claims during login
Expand Down
220 changes: 220 additions & 0 deletions provider_gsuite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package jwtauth

import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"

"github.com/mitchellh/mapstructure"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
)

// GSuiteProvider provides G Suite-specific configuration and behavior.
type GSuiteProvider struct {
config GSuiteProviderConfig // Configuration for the provider
jwtConfig *jwt.Config // Google JWT configuration
adminSvc *admin.Service // Google admin service
}

// GSuiteProviderConfig represents the configuration for a GSuiteProvider.
type GSuiteProviderConfig struct {
// Path to a Google service account key file. Required.
ServiceAccountFilePath string `mapstructure:"gsuite_service_account"`

// Email address of a G Suite admin to impersonate. Required.
AdminImpersonateEmail string `mapstructure:"gsuite_admin_impersonate"`

// If set to true, groups will be fetched from G Suite.
FetchGroups bool `mapstructure:"fetch_groups"`

// If set to true, user info will be fetched from G Suite using UserCustomSchemas.
FetchUserInfo bool `mapstructure:"fetch_user_info"`

// Group membership recursion max depth (0 = do not recurse).
GroupsRecurseMaxDepth int `mapstructure:"groups_recurse_max_depth"`

// Comma-separated list of G Suite custom schemas to fetch as claims.
UserCustomSchemas string `mapstructure:"user_custom_schemas"`

// JSON contents of a Google service account key file.
serviceAccountKeyJSON []byte
}

// Initialize initializes the GSuiteProvider by validating and creating configuration.
func (g *GSuiteProvider) Initialize(jc *jwtConfig) error {
// Decode the provider config
var config GSuiteProviderConfig
if err := mapstructure.Decode(jc.ProviderConfig, &config); err != nil {
return err
}

// Read the Google service account key file
keyJSON, err := ioutil.ReadFile(config.ServiceAccountFilePath)
if err != nil {
return err
}
config.serviceAccountKeyJSON = keyJSON

return g.initialize(config)
}

func (g *GSuiteProvider) initialize(config GSuiteProviderConfig) error {
var err error

// Validate configuration
if config.ServiceAccountFilePath == "" {
return errors.New("'gsuite_service_account' must be set to the file path for a " +
"service account key")
}
if config.AdminImpersonateEmail == "" {
return errors.New("'gsuite_admin_impersonate' must be set to an email address of a " +
"G Suite user with 'Read' permission to access the G Suite Admin User and Group APIs")
}
if config.GroupsRecurseMaxDepth < 0 {
return errors.New("'gsuite_recurse_max_depth' must be a positive integer")
}

// Create the google JWT config from the service account key file
if g.jwtConfig, err = google.JWTConfigFromJSON(config.serviceAccountKeyJSON,
admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryUserReadonlyScope); err != nil {
return err
}

// Set the subject to impersonate and config
g.jwtConfig.Subject = config.AdminImpersonateEmail
g.config = config
return nil
}

// SensitiveKeys returns keys that should be redacted when reading the config of this provider
func (g *GSuiteProvider) SensitiveKeys() []string {
return []string{}
}

// FetchGroups fetches and returns groups from G Suite.
func (g *GSuiteProvider) FetchGroups(b *jwtAuthBackend, allClaims map[string]interface{}, role *jwtRole) (interface{}, error) {
if !g.config.FetchGroups {
return nil, nil
}

userName, err := g.getUserClaim(b, allClaims, role)
if err != nil {
return nil, err
}

// Set context and create a new admin service for requests to Google admin APIs
g.adminSvc, err = admin.NewService(b.providerCtx, option.WithHTTPClient(g.jwtConfig.Client(b.providerCtx)))
if err != nil {
return nil, err
}

// Get the G Suite groups
userGroupsMap := make(map[string]bool)
if err := g.search(b.providerCtx, userGroupsMap, userName, g.config.GroupsRecurseMaxDepth); err != nil {
return nil, err
}

// Convert set of groups to list
var userGroups = make([]interface{}, 0, len(userGroupsMap))
for email := range userGroupsMap {
userGroups = append(userGroups, email)
}

b.Logger().Debug("fetched G Suite groups", "groups", userGroups)
return userGroups, nil
}

// search recursively searches for G Suite groups based on a configured depth for this provider.
func (g *GSuiteProvider) search(ctx context.Context, visited map[string]bool, userName string, depth int) error {
call := g.adminSvc.Groups.List().UserKey(userName).Fields("nextPageToken", "groups(email)")
if err := call.Pages(ctx, func(groups *admin.Groups) error {
var newGroups []string
for _, group := range groups.Groups {
if _, ok := visited[group.Email]; ok {
continue
}
visited[group.Email] = true
newGroups = append(newGroups, group.Email)
}
// Only recursively search for new groups that haven't been seen
if depth > 0 {
for _, email := range newGroups {
if err := g.search(ctx, visited, email, depth-1); err != nil {
return err
}
}
}
return nil
}); err != nil {
return err
}
return nil
}

// FetchUserInfo fetches additional user information from G Suite using custom schemas.
func (g *GSuiteProvider) FetchUserInfo(b *jwtAuthBackend, allClaims map[string]interface{}, role *jwtRole) error {
if !g.config.FetchUserInfo || g.config.UserCustomSchemas == "" {
if g.config.UserCustomSchemas != "" {
b.Logger().Warn(fmt.Sprintf("must set 'fetch_user_info=true' to fetch 'user_custom_schemas': %s", g.config.UserCustomSchemas))
}

return nil
}

userName, err := g.getUserClaim(b, allClaims, role)
if err != nil {
return err
}

// Set context and create a new admin service for requests to Google admin APIs
g.adminSvc, err = admin.NewService(b.providerCtx, option.WithHTTPClient(g.jwtConfig.Client(b.providerCtx)))
if err != nil {
return err
}

return g.fillCustomSchemas(b.providerCtx, userName, allClaims)
}

// fillCustomSchemas fetches G Suite user information associated with the custom schemas
// configured for this provider. It inserts the schema -> value pairs into the passed
// allClaims so that the values can be used for claim mapping to token and identity metadata.
func (g *GSuiteProvider) fillCustomSchemas(ctx context.Context, userName string, allClaims map[string]interface{}) error {
userResponse, err := g.adminSvc.Users.Get(userName).Context(ctx).Projection("custom").
CustomFieldMask(g.config.UserCustomSchemas).Fields("customSchemas").Do()
if err != nil {
return err
}

for schema, rawValue := range userResponse.CustomSchemas {
// note: metadata extraction via claim_mappings only supports strings
// as values, but filtering happens later so we must use interface{}
var value map[string]interface{}
if err := json.Unmarshal(rawValue, &value); err != nil {
return err
}

allClaims[schema] = value
}

return nil
}

// getUserClaim returns the user claim value configured in the passed role.
// If the user claim is not found or is not a string, an error is returned.
func (g *GSuiteProvider) getUserClaim(b *jwtAuthBackend, allClaims map[string]interface{}, role *jwtRole) (string, error) {
userClaimRaw := getClaim(b.Logger(), allClaims, role.UserClaim)
if userClaimRaw == nil {
return "", fmt.Errorf("unable to locate %q in claims", role.UserClaim)
}
userClaim, ok := userClaimRaw.(string)
if !ok {
return "", fmt.Errorf("claim %q could not be converted to string", role.UserClaim)
}

return userClaim, nil
}
Loading

0 comments on commit 9523642

Please sign in to comment.