Skip to content

Commit

Permalink
Merged oidcmapping auth manager into oidc
Browse files Browse the repository at this point in the history
  • Loading branch information
glpatcern committed Feb 17, 2022
1 parent 0a763d1 commit a7f1cea
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 406 deletions.
7 changes: 7 additions & 0 deletions changelog/unreleased/unify-oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Refactor: Merged oidcmapping auth manager into oidc

The oidcmapping auth manager was created as a separate package to ease testing. As it has now been tested
also as a pure OIDC auth provider without mapping, and as the code is largely refactored, it makes
sense to merge it back so to maintain a single OIDC manager.

https://github.com/cs3org/reva/pull/2561
197 changes: 151 additions & 46 deletions pkg/auth/manager/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,28 @@ package oidc

import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"time"

oidc "github.com/coreos/go-oidc"
authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/cs3org/reva/pkg/appctx"
"github.com/cs3org/reva/pkg/auth"
"github.com/cs3org/reva/pkg/auth/manager/registry"
"github.com/cs3org/reva/pkg/auth/scope"
"github.com/cs3org/reva/pkg/errtypes"
"github.com/cs3org/reva/pkg/rgrpc/status"
"github.com/cs3org/reva/pkg/rgrpc/todo/pool"
"github.com/cs3org/reva/pkg/rhttp"
"github.com/cs3org/reva/pkg/sharedconf"
"github.com/juliangruber/go-intersect"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
)

Expand All @@ -47,24 +52,36 @@ func init() {
}

type mgr struct {
provider *oidc.Provider // cached on first request
c *config
provider *oidc.Provider // cached on first request
c *config
oidcUsersMapping map[string]*oidcUserMapping
}

type config struct {
Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."`
Issuer string `mapstructure:"issuer" docs:";The issuer of the OIDC token."`
IDClaim string `mapstructure:"id_claim" docs:"sub;The claim containing the ID of the user."`
UIDClaim string `mapstructure:"uid_claim" docs:";The claim containing the UID of the user."`
GIDClaim string `mapstructure:"gid_claim" docs:";The claim containing the GID of the user."`
GatewaySvc string `mapstructure:"gatewaysvc" docs:";The endpoint at which the GRPC gateway is exposed."`
Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."`
Issuer string `mapstructure:"issuer" docs:";The issuer of the OIDC token."`
IDClaim string `mapstructure:"id_claim" docs:"sub;The claim containing the ID of the user."`
UIDClaim string `mapstructure:"uid_claim" docs:";The claim containing the UID of the user."`
GIDClaim string `mapstructure:"gid_claim" docs:";The claim containing the GID of the user."`
GatewaySvc string `mapstructure:"gatewaysvc" docs:";The endpoint at which the GRPC gateway is exposed."`
UsersMapping string `mapstructure:"users_mapping" docs:"; The optional OIDC users mapping file path"`
GroupClaim string `mapstructure:"group_claim" docs:"; The group claim to be looked up to map the user (default to 'groups')."`
}

type oidcUserMapping struct {
OIDCIssuer string `mapstructure:"oidc_issuer" json:"oidc_issuer"`
OIDCGroup string `mapstructure:"oidc_group" json:"oidc_group"`
Username string `mapstructure:"username" json:"username"`
}

func (c *config) init() {
if c.IDClaim == "" {
// sub is stable and defined as unique. the user manager needs to take care of the sub to user metadata lookup
c.IDClaim = "sub"
}
if c.GroupClaim == "" {
c.GroupClaim = "groups"
}

c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc)
}
Expand Down Expand Up @@ -95,78 +112,98 @@ func (am *mgr) Configure(m map[string]interface{}) error {
}
c.init()
am.c = c

am.oidcUsersMapping = map[string]*oidcUserMapping{}
if c.UsersMapping == "" {
// no mapping defined, leave the map empty and move on
return nil
}

f, err := ioutil.ReadFile(c.UsersMapping)
if err != nil {
return fmt.Errorf("oidc: error reading the users mapping file: +%v", err)
}
oidcUsers := []*oidcUserMapping{}
err = json.Unmarshal(f, &oidcUsers)
if err != nil {
return fmt.Errorf("oidc: error unmarshalling the users mapping file: +%v", err)
}
for _, u := range oidcUsers {
if _, found := am.oidcUsersMapping[u.OIDCGroup]; found {
return fmt.Errorf("oidc: mapping error, group \"%s\" is mapped to multiple users", u.OIDCGroup)
}
am.oidcUsersMapping[u.OIDCGroup] = u
}

return nil
}

// the clientID it would be empty as we only need to validate the clientSecret variable
// The clientID would be empty as we only need to validate the clientSecret variable
// which contains the access token that we can use to contact the UserInfo endpoint
// and get the user claims.
func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string) (*user.User, map[string]*authpb.Scope, error) {
ctx = am.getOAuthCtx(ctx)
log := appctx.GetLogger(ctx)

oidcProvider, err := am.getOIDCProvider(ctx)
if err != nil {
return nil, nil, fmt.Errorf("error creating oidc provider: +%v", err)
return nil, nil, fmt.Errorf("oidc: error creating oidc provider: +%v", err)
}

oauth2Token := &oauth2.Token{
AccessToken: clientSecret,
}

// query the oidc provider for user info
userInfo, err := oidcProvider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
if err != nil {
return nil, nil, fmt.Errorf("oidc: error getting userinfo: +%v", err)
}

// claims contains the standard OIDC claims like issuer, iat, aud, ... and any other non-standard one.
// claims contains the standard OIDC claims like iss, iat, aud, ... and any other non-standard one.
// TODO(labkode): make claims configuration dynamic from the config file so we can add arbitrary mappings from claims to user struct.
// For now, only the group claim is dynamic.
// TODO(labkode): may do like K8s does it: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
var claims map[string]interface{}
if err := userInfo.Claims(&claims); err != nil {
return nil, nil, fmt.Errorf("oidc: error unmarshaling userinfo claims: %v", err)
}

log.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Msg("unmarshalled userinfo")

if claims["issuer"] == nil { // This is not set in simplesamlphp
claims["issuer"] = am.c.Issuer
if claims["iss"] == nil { // This is not set in simplesamlphp
claims["iss"] = am.c.Issuer
}
if claims["email_verified"] == nil { // This is not set in simplesamlphp
claims["email_verified"] = false
}
if claims["preferred_username"] == nil {
claims["preferred_username"] = claims[am.c.IDClaim]
}
if claims["preferred_username"] == nil {
claims["preferred_username"] = claims["email"]
}
if claims["name"] == nil {
claims["name"] = claims[am.c.IDClaim]
}

if claims["email"] == nil {
return nil, nil, fmt.Errorf("no \"email\" attribute found in userinfo: maybe the client did not request the oidc \"email\"-scope")
}

userClaim := "preferred_username"
if claims["preferred_username"] == nil {
if claims["email"] != nil {
userClaim = "email"
} else {
return nil, nil, fmt.Errorf("no \"preferred_username\" and \"email\" attribute found in userinfo: maybe the client did not request the oidc \"profile\"-scope")
}
}
if claims["name"] == nil {
return nil, nil, fmt.Errorf("no \"name\" attribute found in userinfo: maybe the client did not request the oidc \"profile\"-scope")
}

var uid, gid float64
if am.c.UIDClaim != "" {
uid, _ = claims[am.c.UIDClaim].(float64)
if claims["email"] == nil {
return nil, nil, fmt.Errorf("no \"email\" attribute found in userinfo: maybe the client did not request the oidc \"email\"-scope")
}
if am.c.GIDClaim != "" {
gid, _ = claims[am.c.GIDClaim].(float64)

err = am.resolveUser(ctx, claims)
if err != nil {
return nil, nil, errors.Wrapf(err, "oidc: error resolving username for external user '%v'", claims["email"])
}

userID := &user.UserId{
OpaqueId: claims[am.c.IDClaim].(string), // a stable non reassignable id
Idp: claims["issuer"].(string), // in the scope of this issuer
Idp: claims["iss"].(string), // in the scope of this issuer
Type: getUserType(claims[am.c.IDClaim].(string)),
}

gwc, err := pool.GetGatewayServiceClient(am.c.GatewaySvc)
if err != nil {
return nil, nil, errors.Wrap(err, "oidc: error getting gateway grpc client")
Expand All @@ -175,25 +212,29 @@ func (am *mgr) Authenticate(ctx context.Context, clientID, clientSecret string)
UserId: userID,
})
if err != nil {
return nil, nil, errors.Wrap(err, "oidc: error getting user groups")
return nil, nil, errors.Wrapf(err, "oidc: error getting user groups for '%+v'", userID)
}
if getGroupsResp.Status.Code != rpc.Code_CODE_OK {
return nil, nil, errors.Wrap(err, "oidc: grpc getting user groups failed")
return nil, nil, status.NewErrorFromCode(getGroupsResp.Status.Code, "oidc")
}

var uid, gid int64
if am.c.UIDClaim != "" {
uid, _ = claims[am.c.UIDClaim].(int64)
}
if am.c.GIDClaim != "" {
gid, _ = claims[am.c.GIDClaim].(int64)
}

u := &user.User{
Id: userID,
Username: claims[userClaim].(string),
// TODO(labkode) if we can get groups from the claim we need to give the possibility
// to the admin to choose what claim provides the groups.
// TODO(labkode) ... use all claims from oidc?
// TODO(labkode): do like K8s does it: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
Id: userID,
Username: claims["preferred_username"].(string),
Groups: getGroupsResp.Groups,
Mail: claims["email"].(string),
MailVerified: claims["email_verified"].(bool),
DisplayName: claims["name"].(string),
UidNumber: int64(uid),
GidNumber: int64(gid),
UidNumber: uid,
GidNumber: gid,
}

var scopes map[string]*authpb.Scope
Expand Down Expand Up @@ -226,24 +267,88 @@ func (am *mgr) getOAuthCtx(ctx context.Context) context.Context {
return ctx
}

// getOIDCProvider returns a singleton OIDC provider
func (am *mgr) getOIDCProvider(ctx context.Context) (*oidc.Provider, error) {
ctx = am.getOAuthCtx(ctx)
log := appctx.GetLogger(ctx)

if am.provider != nil {
return am.provider, nil
}

// Initialize a provider by specifying the issuer URL.
// Once initialized is a singleton that is reused if further requests.
// Once initialized this is a singleton that is reused for further requests.
// The provider is responsible to verify the token sent by the client
// against the security keys oftentimes available in the .well-known endpoint.
provider, err := oidc.NewProvider(ctx, am.c.Issuer)

if err != nil {
return nil, fmt.Errorf("error creating a new oidc provider: %+v", err)
log.Error().Err(err).Msg("oidc: error creating a new oidc provider")
return nil, fmt.Errorf("oidc: error creating a new oidc provider: %+v", err)
}

am.provider = provider
return am.provider, nil
}

func (am *mgr) resolveUser(ctx context.Context, claims map[string]interface{}) error {
if len(am.oidcUsersMapping) > 0 {
var username string

// map and discover the user's username when a mapping is defined
if claims[am.c.GroupClaim] == nil {
// we are required to perform a user mapping but the group claim is not available
return fmt.Errorf("no \"%s\" claim found in userinfo to map user", am.c.GroupClaim)
}
mappings := make([]string, 0, len(am.oidcUsersMapping))
for _, m := range am.oidcUsersMapping {
if m.OIDCIssuer == claims["iss"] {
mappings = append(mappings, m.OIDCGroup)
}
}

intersection := intersect.Simple(claims[am.c.GroupClaim], mappings)
if len(intersection) > 1 {
// multiple mappings are not implemented as we cannot decide which one to choose
return errtypes.PermissionDenied("more than one user mapping entry exists for the given group claims")
}
if len(intersection) == 0 {
return errtypes.PermissionDenied("no user mapping found for the given group claim(s)")
}
for _, m := range intersection {
username = am.oidcUsersMapping[m.(string)].Username
}

upsc, err := pool.GetUserProviderServiceClient(am.c.GatewaySvc)
if err != nil {
return errors.Wrap(err, "error getting user provider grpc client")
}
getUserByClaimResp, err := upsc.GetUserByClaim(ctx, &user.GetUserByClaimRequest{
Claim: "username",
Value: username,
})
if err != nil {
return errors.Wrapf(err, "error getting user by username '%v'", username)
}
if getUserByClaimResp.Status.Code != rpc.Code_CODE_OK {
return status.NewErrorFromCode(getUserByClaimResp.Status.Code, "oidc")
}

// take the properties of the mapped target user to override the claims
claims["preferred_username"] = username
claims[am.c.IDClaim] = getUserByClaimResp.GetUser().GetId().OpaqueId
claims["iss"] = getUserByClaimResp.GetUser().GetId().Idp
if am.c.UIDClaim != "" {
claims[am.c.UIDClaim] = getUserByClaimResp.GetUser().UidNumber
}
if am.c.GIDClaim != "" {
claims[am.c.GIDClaim] = getUserByClaimResp.GetUser().GidNumber
}
appctx.GetLogger(ctx).Debug().Str("username", username).Interface("claims", claims).Msg("resolveUser: claims overridden from mapped user")
}
return nil
}

func getUserType(upn string) user.UserType {
var t user.UserType
switch {
Expand Down
Loading

0 comments on commit a7f1cea

Please sign in to comment.