Repository: dexidp/dex
Pull request #1612 (2020-01-03 09:42:25)

connector/ldap: add multiple user to group mapping
**This PR changes Dex LDAP connector config structure!**

Add an ability to fetch user's membership from groups of a different type by specifying multiple group attribute to user attribute value matchers in the Dex config:
```yaml
groupSearch:
  userMatchers:
  - userAttr: uid
    groupAttr: memberUid
  - userAttr: DN
    groupAttr: member
```

  In other words the user's groups can be fetched now from ldap structure similar to the following:

    dn: cn=john,ou=People,dc=example,dc=org
    objectClass: person
    objectClass: inetOrgPerson
    sn: doe
    cn: john
    uid: johndoe
    mail: johndoe@example.com
    userpassword: bar

    dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
    objectClass: groupOfNames
    cn: qa
    member: cn=john,ou=People,dc=example,dc=org

    dn: cn=logger,ou=UnixGroups,ou=Portland,dc=example,dc=org
    objectClass: posixGroup
    gidNumber: 1000
    cn: logger
    memberUid: johndoe


Patch:
diff --git a/Documentation/connectors/ldap.md b/Documentation/connectors/ldap.md
index 4bd540b5..e69c3005 100644
--- a/Documentation/connectors/ldap.md
+++ b/Documentation/connectors/ldap.md
@@ -123,11 +123,12 @@ connectors:
       # Optional filter to apply when searching the directory.
       filter: "(objectClass=group)"
 
-      # Following two fields are used to match a user to a group. It adds an additional
+      # Following list contains field pairs that are used to match a user to a group. It adds an additional
       # requirement to the filter that an attribute in the group must match the user's
       # attribute value.
-      userAttr: uid
-      groupAttr: member
+      userMatchers:
+      - userAttr: uid
+        groupAttr: member
 
       # Represents group name.
       nameAttr: name
@@ -215,8 +216,9 @@ groupSearch:
 
   # The group search needs to match the "uid" attribute on
   # the user with the "memberUid" attribute on the group.
-  userAttr: uid
-  groupAttr: memberUid
+  userMatchers:
+  - userAttr: uid
+    groupAttr: memberUid
 
   # Unique name of the group.
   nameAttr: cn
@@ -242,8 +244,27 @@ groupSearch:
   # Optional filter to apply when searching the directory.
   filter: "(objectClass=group)"
 
-  userAttr: DN # Use "DN" here not "uid"
-  groupAttr: member
+  userMatchers:
+  - userAttr: DN # Use "DN" here not "uid"
+    groupAttr: member
+
+  nameAttr: name
+```
+
+There are cases when different types (objectClass) of groups use different attributes to keep a list of members. Below is an example of group query for such case:
+
+```yaml
+groupSearch:
+  baseDN: cn=groups,cn=compat,dc=example,dc=com
+  # Optional filter to search for different group types
+  filter: "(|(objectClass=posixGroup)(objectClass=group))"
+
+  # Use multiple user matchers so Dex will know which attribute names should be used to search for group members
+  userMatchers:
+  - userAttr: uid
+    groupAttr: memberUid
+  - userAttr: DN
+    groupAttr: member
 
   nameAttr: name
 ```
@@ -275,8 +296,9 @@ connectors:
       # Would translate to the query "(&(objectClass=group)(member=<user uid>))".
       baseDN: cn=groups,dc=freeipa,dc=example,dc=com
       filter: "(objectClass=group)"
-      userAttr: uid
-      groupAttr: member
+      userMatchers:
+      - userAttr: uid
+        groupAttr: member
       nameAttr: name
 ```
 
@@ -315,8 +337,9 @@ connectors:
     groupSearch:
       baseDN: cn=Users,dc=example,dc=com
       filter: "(objectClass=group)"
-      userAttr: DN
-      groupAttr: member
+      userMatchers:
+      - userAttr: DN
+        groupAttr: member
       nameAttr: cn
 ```
 
diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go
index cac3539f..53f9062f 100644
--- a/connector/ldap/ldap.go
+++ b/connector/ldap/ldap.go
@@ -41,16 +41,26 @@ import (
 //         nameAttr: name
 //         preferredUsernameAttr: uid
 //       groupSearch:
-//         # Would translate to the query "(&(objectClass=group)(member=<user uid>))"
+//         # Would translate to the separate query per user matcher pair and aggregate results into a single group list:
+//         #  "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(memberUid=<user uid>))"
+//         #  "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(member=<user DN>))"
 //         baseDN: cn=groups,dc=example,dc=com
-//         filter: "(objectClass=group)"
-//         userAttr: uid
-//         # Use if full DN is needed and not available as any other attribute
-//         # Will only work if "DN" attribute does not exist in the record
-//         # userAttr: DN
-//         groupAttr: member
+//         filter: "(|(objectClass=posixGroup)(objectClass=groupOfNames))"
+//         userMatchers:
+//         - userAttr: uid
+//           groupAttr: memberUid
+//           # Use if full DN is needed and not available as any other attribute
+//           # Will only work if "DN" attribute does not exist in the record:
+//         - userAttr: DN
+//           groupAttr: member
 //         nameAttr: name
 //
+
+type UserMatcher struct {
+	UserAttr  string `json:"userAttr"`
+	GroupAttr string `json:"groupAttr"`
+}
+
 type Config struct {
 	// The host and optional port of the LDAP server. If port isn't supplied, it will be
 	// guessed based on the TLS configuration. 389 or 636.
@@ -124,16 +134,22 @@ type Config struct {
 
 		Scope string `json:"scope"` // Defaults to "sub"
 
-		// These two fields are use to match a user to a group.
+		// DEPRECATED config options. Those are left for backward compatibility.
+		// See "UserMatchers" below for the current group to user matching implementation
+		// TODO: should be eventually removed from the code
+		UserAttr  string `json:"userAttr"`
+		GroupAttr string `json:"groupAttr"`
+
+		// Array of the field pairs used to match a user to a group.
+		// See the "UserMatcher" struct for the exact field names
 		//
-		// It adds an additional requirement to the filter that an attribute in the group
+		// Each pair adds an additional requirement to the filter that an attribute in the group
 		// match the user's attribute value. For example that the "members" attribute of
 		// a group matches the "uid" of the user. The exact filter being added is:
 		//
-		//   (<groupAttr>=<userAttr value>)
+		//   (userMatchers[n].<groupAttr>=userMatchers[n].<userAttr value>)
 		//
-		UserAttr  string `json:"userAttr"`
-		GroupAttr string `json:"groupAttr"`
+		UserMatchers []UserMatcher `json:"userMatchers"`
 
 		// The attribute of the group that represents its name.
 		NameAttr string `json:"nameAttr"`
@@ -165,6 +181,23 @@ func parseScope(s string) (int, bool) {
 	return 0, false
 }
 
+// Build a list of group attr name to user attr value matchers.
+// Function exists here to allow backward compatibility between old and new
+// group to user matching implementations.
+// See "Config.GroupSearch.UserMatchers" comments for the details
+func (c *ldapConnector) userMatchers() []UserMatcher {
+	if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" {
+		return c.GroupSearch.UserMatchers[:]
+	} else {
+		return []UserMatcher{
+			{
+				UserAttr:  c.GroupSearch.UserAttr,
+				GroupAttr: c.GroupSearch.GroupAttr,
+			},
+		}
+	}
+}
+
 // Open returns an authentication strategy using LDAP.
 func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
 	conn, err := c.OpenConnector(logger)
@@ -378,11 +411,14 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
 		Attributes: []string{
 			c.UserSearch.IDAttr,
 			c.UserSearch.EmailAttr,
-			c.GroupSearch.UserAttr,
 			// TODO(ericchiang): what if this contains duplicate values?
 		},
 	}
 
+	for _, matcher := range c.userMatchers() {
+		req.Attributes = append(req.Attributes, matcher.UserAttr)
+	}
+
 	if c.UserSearch.NameAttr != "" {
 		req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
 	}
@@ -536,36 +572,38 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
 	}
 
 	var groups []*ldap.Entry
-	for _, attr := range getAttrs(user, c.GroupSearch.UserAttr) {
-		filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(attr))
-		if c.GroupSearch.Filter != "" {
-			filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
-		}
+	for _, matcher := range c.userMatchers() {
+		for _, attr := range getAttrs(user, matcher.UserAttr) {
+			filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr))
+			if c.GroupSearch.Filter != "" {
+				filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
+			}
 
-		req := &ldap.SearchRequest{
-			BaseDN:     c.GroupSearch.BaseDN,
-			Filter:     filter,
-			Scope:      c.groupSearchScope,
-			Attributes: []string{c.GroupSearch.NameAttr},
-		}
+			req := &ldap.SearchRequest{
+				BaseDN:     c.GroupSearch.BaseDN,
+				Filter:     filter,
+				Scope:      c.groupSearchScope,
+				Attributes: []string{c.GroupSearch.NameAttr},
+			}
 
-		gotGroups := false
-		if err := c.do(ctx, func(conn *ldap.Conn) error {
-			c.logger.Infof("performing ldap search %s %s %s",
-				req.BaseDN, scopeString(req.Scope), req.Filter)
-			resp, err := conn.Search(req)
-			if err != nil {
-				return fmt.Errorf("ldap: search failed: %v", err)
+			gotGroups := false
+			if err := c.do(ctx, func(conn *ldap.Conn) error {
+				c.logger.Infof("performing ldap search %s %s %s",
+					req.BaseDN, scopeString(req.Scope), req.Filter)
+				resp, err := conn.Search(req)
+				if err != nil {
+					return fmt.Errorf("ldap: search failed: %v", err)
+				}
+				gotGroups = len(resp.Entries) != 0
+				groups = append(groups, resp.Entries...)
+				return nil
+			}); err != nil {
+				return nil, err
+			}
+			if !gotGroups {
+				// TODO(ericchiang): Is this going to spam the logs?
+				c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
 			}
-			gotGroups = len(resp.Entries) != 0
-			groups = append(groups, resp.Entries...)
-			return nil
-		}); err != nil {
-			return nil, err
-		}
-		if !gotGroups {
-			// TODO(ericchiang): Is this going to spam the logs?
-			c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
 		}
 	}
 
diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go
index 142a7c6c..d4ce2e67 100644
--- a/connector/microsoft/microsoft.go
+++ b/connector/microsoft/microsoft.go
@@ -31,6 +31,7 @@ const (
 )
 
 const (
+	apiURL = "https://graph.microsoft.com"
 	// Microsoft requires this scope to access user's profile
 	scopeUser = "user.read"
 	// Microsoft requires this scope to list groups the user is a member of
@@ -53,8 +54,6 @@ type Config struct {
 // Open returns a strategy for logging in through Microsoft.
 func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
 	m := microsoftConnector{
-		apiURL:               "https://login.microsoftonline.com",
-		graphURL:             "https://graph.microsoft.com",
 		redirectURI:          c.RedirectURI,
 		clientID:             c.ClientID,
 		clientSecret:         c.ClientSecret,
@@ -95,8 +94,6 @@ var (
 )
 
 type microsoftConnector struct {
-	apiURL               string
-	graphURL             string
 	redirectURI          string
 	clientID             string
 	clientSecret         string
@@ -126,8 +123,8 @@ func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Confi
 		ClientID:     c.clientID,
 		ClientSecret: c.clientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  c.apiURL + "/" + c.tenant + "/oauth2/v2.0/authorize",
-			TokenURL: c.apiURL + "/" + c.tenant + "/oauth2/v2.0/token",
+			AuthURL:  "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/authorize",
+			TokenURL: "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/token",
 		},
 		Scopes:      microsoftScopes,
 		RedirectURL: c.redirectURI,
@@ -299,7 +296,7 @@ type user struct {
 
 func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) {
 	// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get
-	req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil)
+	req, err := http.NewRequest("GET", apiURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil)
 	if err != nil {
 		return u, fmt.Errorf("new req: %v", err)
 	}
@@ -358,7 +355,7 @@ func (c *microsoftConnector) getGroupIDs(ctx context.Context, client *http.Clien
 	in := &struct {
 		SecurityEnabledOnly bool `json:"securityEnabledOnly"`
 	}{c.onlySecurityGroups}
-	reqURL := c.graphURL + "/v1.0/me/getMemberGroups"
+	reqURL := apiURL + "/v1.0/me/getMemberGroups"
 	for {
 		var out []string
 		var next string
@@ -386,7 +383,7 @@ func (c *microsoftConnector) getGroupNames(ctx context.Context, client *http.Cli
 		IDs   []string `json:"ids"`
 		Types []string `json:"types"`
 	}{ids, []string{"group"}}
-	reqURL := c.graphURL + "/v1.0/directoryObjects/getByIds"
+	reqURL := apiURL + "/v1.0/directoryObjects/getByIds"
 	for {
 		var out []group
 		var next string
diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go
index ee6b24a8..2c199112 100644
--- a/connector/oidc/oidc.go
+++ b/connector/oidc/oidc.go
@@ -262,25 +262,15 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
 	if !found {
 		return identity, fmt.Errorf("missing \"%s\" claim", userNameKey)
 	}
-
-	hasEmailScope := false
-	for _, s := range c.oauth2Config.Scopes {
-		if s == "email" {
-			hasEmailScope = true
-			break
-		}
-	}
-
 	email, found := claims["email"].(string)
-	if !found && hasEmailScope {
+	if !found {
 		return identity, errors.New("missing \"email\" claim")
 	}
-
 	emailVerified, found := claims["email_verified"].(bool)
 	if !found {
 		if c.insecureSkipEmailVerified {
 			emailVerified = true
-		} else if hasEmailScope {
+		} else {
 			return identity, errors.New("missing \"email_verified\" claim")
 		}
 	}
diff --git a/examples/config-ad-kubelogin.yaml b/examples/config-ad-kubelogin.yaml
index 20bb9bd8..559ccae9 100644
--- a/examples/config-ad-kubelogin.yaml
+++ b/examples/config-ad-kubelogin.yaml
@@ -41,10 +41,11 @@ connectors:
       baseDN: cn=Users,dc=example,dc=com
       filter: "(objectClass=group)"
 
+      userMatchers:
       # A user is a member of a group when their DN matches
       # the value of a "member" attribute on the group entity.
-      userAttr: DN
-      groupAttr: member
+      - userAttr: DN
+        groupAttr: member
 
       # The group name should be the "cn" value.
       nameAttr: cn
diff --git a/examples/config-ldap.yaml b/examples/config-ldap.yaml
index 21fb052c..05265b4b 100644
--- a/examples/config-ldap.yaml
+++ b/examples/config-ldap.yaml
@@ -37,10 +37,11 @@ connectors:
       baseDN: ou=Groups,dc=example,dc=org
       filter: "(objectClass=groupOfNames)"
 
-      # A user is a member of a group when their DN matches
-      # the value of a "member" attribute on the group entity.
-      userAttr: DN
-      groupAttr: member
+      userMatchers:
+        # A user is a member of a group when their DN matches
+        # the value of a "member" attribute on the group entity.
+      - userAttr: DN
+        groupAttr: member
 
       # The group name should be the "cn" value.
       nameAttr: cn
diff --git a/storage/sql/config.go b/storage/sql/config.go
index f3dede4a..827b0df1 100644
--- a/storage/sql/config.go
+++ b/storage/sql/config.go
@@ -308,13 +308,6 @@ func (s *MySQL) open(logger log.Logger) (*conn, error) {
 		return nil, err
 	}
 
-	if s.MaxIdleConns == 0 {
-		/*Override default behaviour to fix https://github.com/dexidp/dex/issues/1608*/
-		db.SetMaxIdleConns(0)
-	} else {
-		db.SetMaxIdleConns(s.MaxIdleConns)
-	}
-
 	err = db.Ping()
 	if err != nil {
 		if mysqlErr, ok := err.(*mysql.MySQLError); ok && mysqlErr.Number == mysqlErrUnknownSysVar {


Test patch:
diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go
index 1ed2f878..1dc26afd 100644
--- a/connector/ldap/ldap_test.go
+++ b/connector/ldap/ldap_test.go
@@ -307,8 +307,12 @@ member: cn=jane,ou=People,dc=example,dc=org
 	c.UserSearch.IDAttr = "DN"
 	c.UserSearch.Username = "cn"
 	c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
-	c.GroupSearch.UserAttr = "DN"
-	c.GroupSearch.GroupAttr = "member"
+	c.GroupSearch.UserMatchers = []UserMatcher{
+		{
+			UserAttr:  "DN",
+			GroupAttr: "member",
+		},
+	}
 	c.GroupSearch.NameAttr = "cn"
 
 	tests := []subtest{
@@ -400,8 +404,12 @@ gidNumber: 1002
 	c.UserSearch.IDAttr = "DN"
 	c.UserSearch.Username = "cn"
 	c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
-	c.GroupSearch.UserAttr = "departmentNumber"
-	c.GroupSearch.GroupAttr = "gidNumber"
+	c.GroupSearch.UserMatchers = []UserMatcher{
+		{
+			UserAttr:  "departmentNumber",
+			GroupAttr: "gidNumber",
+		},
+	}
 	c.GroupSearch.NameAttr = "cn"
 	tests := []subtest{
 		{
@@ -485,6 +493,243 @@ cn: admins
 member: cn=john,ou=People,dc=example,dc=org
 member: cn=jane,ou=People,dc=example,dc=org
 
+dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: groupOfNames
+cn: developers
+member: cn=jane,ou=People,dc=example,dc=org
+`
+	c := &Config{}
+	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
+	c.UserSearch.NameAttr = "cn"
+	c.UserSearch.EmailAttr = "mail"
+	c.UserSearch.IDAttr = "DN"
+	c.UserSearch.Username = "cn"
+	c.GroupSearch.BaseDN = "dc=example,dc=org"
+	c.GroupSearch.UserMatchers = []UserMatcher{
+		{
+			UserAttr:  "DN",
+			GroupAttr: "member",
+		},
+	}
+	c.GroupSearch.NameAttr = "cn"
+	c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
+
+	tests := []subtest{
+		{
+			name:     "validpassword",
+			username: "jane",
+			password: "foo",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=jane,ou=People,dc=example,dc=org",
+				Username:      "jane",
+				Email:         "janedoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"admins", "developers"},
+			},
+		},
+		{
+			name:     "validpassword2",
+			username: "john",
+			password: "bar",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=john,ou=People,dc=example,dc=org",
+				Username:      "john",
+				Email:         "johndoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"admins"},
+			},
+		},
+	}
+
+	runTests(t, schema, connectLDAP, c, tests)
+}
+
+func TestGroupToUserMatchers(t *testing.T) {
+	schema := `
+dn: ou=People,dc=example,dc=org
+objectClass: organizationalUnit
+ou: People
+
+dn: cn=jane,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: jane
+uid: janedoe
+mail: janedoe@example.com
+userpassword: foo
+
+dn: cn=john,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: john
+uid: johndoe
+mail: johndoe@example.com
+userpassword: bar
+
+# Group definitions.
+
+dn: ou=Seattle,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Seattle
+
+dn: ou=Portland,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Portland
+
+dn: ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: ou=UnixGroups,ou=Seattle,dc=example,dc=org
+objectClass: organizationalUnit
+ou: UnixGroups
+
+dn: ou=Groups,ou=Portland,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: ou=UnixGroups,ou=Portland,dc=example,dc=org
+objectClass: organizationalUnit
+ou: UnixGroups
+
+dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
+objectClass: groupOfNames
+cn: qa
+member: cn=john,ou=People,dc=example,dc=org
+
+dn: cn=logger,ou=UnixGroups,ou=Portland,dc=example,dc=org
+objectClass: posixGroup
+gidNumber: 1000
+cn: logger
+memberUid: johndoe
+
+dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: groupOfNames
+cn: admins
+member: cn=john,ou=People,dc=example,dc=org
+member: cn=jane,ou=People,dc=example,dc=org
+
+dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: groupOfNames
+cn: developers
+member: cn=jane,ou=People,dc=example,dc=org
+
+dn: cn=frontend,ou=UnixGroups,ou=Seattle,dc=example,dc=org
+objectClass: posixGroup
+gidNumber: 1001
+cn: frontend
+memberUid: janedoe
+`
+	c := &Config{}
+	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
+	c.UserSearch.NameAttr = "cn"
+	c.UserSearch.EmailAttr = "mail"
+	c.UserSearch.IDAttr = "DN"
+	c.UserSearch.Username = "cn"
+	c.GroupSearch.BaseDN = "dc=example,dc=org"
+	c.GroupSearch.UserMatchers = []UserMatcher{
+		{
+			UserAttr:  "DN",
+			GroupAttr: "member",
+		},
+		{
+			UserAttr:  "uid",
+			GroupAttr: "memberUid",
+		},
+	}
+	c.GroupSearch.NameAttr = "cn"
+	c.GroupSearch.Filter = "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // search all group types
+
+	tests := []subtest{
+		{
+			name:     "validpassword",
+			username: "jane",
+			password: "foo",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=jane,ou=People,dc=example,dc=org",
+				Username:      "jane",
+				Email:         "janedoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"admins", "developers", "frontend"},
+			},
+		},
+		{
+			name:     "validpassword2",
+			username: "john",
+			password: "bar",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=john,ou=People,dc=example,dc=org",
+				Username:      "john",
+				Email:         "johndoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"qa", "admins", "logger"},
+			},
+		},
+	}
+
+	runTests(t, schema, connectLDAP, c, tests)
+}
+
+// Test deprecated group to user matching implementation
+// which was left for backward compatibility.
+// See "Config.GroupSearch.UserMatchers" comments for the details
+func TestDeprecatedGroupToUserMatcher(t *testing.T) {
+	schema := `
+dn: ou=People,dc=example,dc=org
+objectClass: organizationalUnit
+ou: People
+
+dn: cn=jane,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: jane
+mail: janedoe@example.com
+userpassword: foo
+
+dn: cn=john,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: john
+mail: johndoe@example.com
+userpassword: bar
+
+# Group definitions.
+
+dn: ou=Seattle,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Seattle
+
+dn: ou=Portland,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Portland
+
+dn: ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: ou=Groups,ou=Portland,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
+objectClass: groupOfNames
+cn: qa
+member: cn=john,ou=People,dc=example,dc=org
+
+dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
+objectClass: groupOfNames
+cn: admins
+member: cn=john,ou=People,dc=example,dc=org
+member: cn=jane,ou=People,dc=example,dc=org
+
 dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
 objectClass: groupOfNames
 cn: developers
diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go
deleted file mode 100644
index 3fba9c2a..00000000
--- a/connector/microsoft/microsoft_test.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package microsoft
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"reflect"
-	"testing"
-
-	"github.com/dexidp/dex/connector"
-)
-
-type testResponse struct {
-	data interface{}
-}
-
-const tenant = "9b1c3439-a67e-4e92-bb0d-0571d44ca965"
-
-var dummyToken = testResponse{data: map[string]interface{}{
-	"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
-	"expires_in":   "30",
-}}
-
-func TestUserIdentityFromGraphAPI(t *testing.T) {
-	s := newTestServer(map[string]testResponse{
-		"/v1.0/me?$select=id,displayName,userPrincipalName": {
-			data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"},
-		},
-		"/" + tenant + "/oauth2/v2.0/token": dummyToken,
-	})
-	defer s.Close()
-
-	req, _ := http.NewRequest("GET", s.URL, nil)
-
-	c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant}
-	identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req)
-	expectNil(t, err)
-	expectEquals(t, identity.Username, "Jane Doe")
-	expectEquals(t, identity.UserID, "S56767889")
-	expectEquals(t, identity.PreferredUsername, "")
-	expectEquals(t, identity.Email, "jane.doe@example.com")
-	expectEquals(t, identity.EmailVerified, true)
-	expectEquals(t, len(identity.Groups), 0)
-}
-
-func TestUserGroupsFromGraphAPI(t *testing.T) {
-	s := newTestServer(map[string]testResponse{
-		"/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}},
-		"/v1.0/me/getMemberGroups": {data: map[string]interface{}{
-			"value": []string{"a", "b"},
-		}},
-		"/" + tenant + "/oauth2/v2.0/token": dummyToken,
-	})
-	defer s.Close()
-
-	req, _ := http.NewRequest("GET", s.URL, nil)
-
-	c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant}
-	identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
-	expectNil(t, err)
-	expectEquals(t, identity.Groups, []string{"a", "b"})
-}
-
-func newTestServer(responses map[string]testResponse) *httptest.Server {
-	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		response, found := responses[r.RequestURI]
-		if !found {
-			fmt.Fprintf(os.Stderr, "Mock response for %q not found\n", r.RequestURI)
-			http.NotFound(w, r)
-			return
-		}
-		w.Header().Add("Content-Type", "application/json")
-		json.NewEncoder(w).Encode(response.data)
-	}))
-	return s
-}
-
-func expectNil(t *testing.T, a interface{}) {
-	if a != nil {
-		t.Errorf("Expected %+v to equal nil", a)
-	}
-}
-
-func expectEquals(t *testing.T, a interface{}, b interface{}) {
-	if !reflect.DeepEqual(a, b) {
-		t.Errorf("Expected %+v to equal %+v", a, b)
-	}
-}
diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go
index 52afa158..a6118b2f 100644
--- a/connector/oidc/oidc_test.go
+++ b/connector/oidc/oidc_test.go
@@ -50,19 +50,16 @@ func TestHandleCallback(t *testing.T) {
 		userIDKey                 string
 		userNameKey               string
 		insecureSkipEmailVerified bool
-		scopes                    []string
 		expectUserID              string
 		expectUserName            string
-		expectedEmailField        string
 		token                     map[string]interface{}
 	}{
 		{
-			name:               "simpleCase",
-			userIDKey:          "", // not configured
-			userNameKey:        "", // not configured
-			expectUserID:       "subvalue",
-			expectUserName:     "namevalue",
-			expectedEmailField: "emailvalue",
+			name:           "simpleCase",
+			userIDKey:      "", // not configured
+			userNameKey:    "", // not configured
+			expectUserID:   "subvalue",
+			expectUserName: "namevalue",
 			token: map[string]interface{}{
 				"sub":            "subvalue",
 				"name":           "namevalue",
@@ -75,7 +72,6 @@ func TestHandleCallback(t *testing.T) {
 			insecureSkipEmailVerified: true,
 			expectUserID:              "subvalue",
 			expectUserName:            "namevalue",
-			expectedEmailField:        "emailvalue",
 			token: map[string]interface{}{
 				"sub":   "subvalue",
 				"name":  "namevalue",
@@ -83,11 +79,10 @@ func TestHandleCallback(t *testing.T) {
 			},
 		},
 		{
-			name:               "withUserIDKey",
-			userIDKey:          "name",
-			expectUserID:       "namevalue",
-			expectUserName:     "namevalue",
-			expectedEmailField: "emailvalue",
+			name:           "withUserIDKey",
+			userIDKey:      "name",
+			expectUserID:   "namevalue",
+			expectUserName: "namevalue",
 			token: map[string]interface{}{
 				"sub":            "subvalue",
 				"name":           "namevalue",
@@ -96,11 +91,10 @@ func TestHandleCallback(t *testing.T) {
 			},
 		},
 		{
-			name:               "withUserNameKey",
-			userNameKey:        "user_name",
-			expectUserID:       "subvalue",
-			expectUserName:     "username",
-			expectedEmailField: "emailvalue",
+			name:           "withUserNameKey",
+			userNameKey:    "user_name",
+			expectUserID:   "subvalue",
+			expectUserName: "username",
 			token: map[string]interface{}{
 				"sub":            "subvalue",
 				"user_name":      "username",
@@ -108,33 +102,6 @@ func TestHandleCallback(t *testing.T) {
 				"email_verified": true,
 			},
 		},
-		{
-			name:                      "emptyEmailScope",
-			expectUserID:              "subvalue",
-			expectUserName:            "namevalue",
-			expectedEmailField:        "",
-			scopes:                    []string{"groups"},
-			insecureSkipEmailVerified: true,
-			token: map[string]interface{}{
-				"sub":       "subvalue",
-				"name":      "namevalue",
-				"user_name": "username",
-			},
-		},
-		{
-			name:                      "emptyEmailScopeButEmailProvided",
-			expectUserID:              "subvalue",
-			expectUserName:            "namevalue",
-			expectedEmailField:        "emailvalue",
-			scopes:                    []string{"groups"},
-			insecureSkipEmailVerified: true,
-			token: map[string]interface{}{
-				"sub":       "subvalue",
-				"name":      "namevalue",
-				"user_name": "username",
-				"email":     "emailvalue",
-			},
-		},
 	}
 
 	for _, tc := range tests {
@@ -144,20 +111,13 @@ func TestHandleCallback(t *testing.T) {
 				t.Fatal("failed to setup test server", err)
 			}
 			defer testServer.Close()
-
-			var scopes []string
-			if len(tc.scopes) > 0 {
-				scopes = tc.scopes
-			} else {
-				scopes = []string{"email", "groups"}
-			}
 			serverURL := testServer.URL
 			basicAuth := true
 			config := Config{
 				Issuer:                    serverURL,
 				ClientID:                  "clientID",
 				ClientSecret:              "clientSecret",
-				Scopes:                    scopes,
+				Scopes:                    []string{"groups"},
 				RedirectURI:               fmt.Sprintf("%s/callback", serverURL),
 				UserIDKey:                 tc.userIDKey,
 				UserNameKey:               tc.userNameKey,
@@ -182,7 +142,7 @@ func TestHandleCallback(t *testing.T) {
 
 			expectEquals(t, identity.UserID, tc.expectUserID)
 			expectEquals(t, identity.Username, tc.expectUserName)
-			expectEquals(t, identity.Email, tc.expectedEmailField)
+			expectEquals(t, identity.Email, "emailvalue")
 			expectEquals(t, identity.EmailVerified, true)
 		})
 	}