Repository: dexidp/dex
Pull request #2740 (2022-11-15 15:19:28)

Add preferredEmailDomain config option for GitHub connector
<!--
Thank you for sending a pull request! Here are some tips for contributors:

1. Fill the description template below.
2. Sign a DCO (if you haven't already signed it).
3. Include appropriate tests (if necessary). Make sure that all CI checks passed.
4. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.
-->

#### Overview

<!-- Describe your changes briefly here. -->
This PR is a revamped version of https://github.com/dexidp/dex/pull/1298.
It allows identifying users by verified company email without marking it as primary.

#### What this PR does / why we need it

<!--
- Please state in detail why we need this PR and what it solves.
- If your PR closes some of the existing issues, please add links to them here.
  Mentioned issues will be automatically closed.
  Usage: "Closes #<issue number>", or "Closes (paste link of issue)"
-->

This PR adds new `preferredEmailDomain` config option for GitHub connector.
If set, an email address with the specified domain takes precedence over the primary email.

When a user is logged into a service that uses Dex, such as ArgoCD, via GitHub, the user is identified by the account's primary email address.

However, in a company, we have the following needs:
* Identify employees by their company domain email address
* Allow employees to use their own(not-for-only-work) GitHub account

To satisfy these, we need an option that can return a specific domain email address as the user's email address.

#### Special notes for your reviewer

N/A

#### Does this PR introduce a user-facing change?

<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
Add new `preferredEmailDomain` config option for GitHub connector.
```


nobuyo: @sagikazarmark @nabokihms would it be ok if i ask you to review about this?

Patch:
diff --git a/api/v2/go.mod b/api/v2/go.mod
index a704df7c..81eee5d1 100644
--- a/api/v2/go.mod
+++ b/api/v2/go.mod
@@ -3,14 +3,14 @@ module github.com/dexidp/dex/api/v2
 go 1.17
 
 require (
-	google.golang.org/grpc v1.49.0
+	google.golang.org/grpc v1.51.0
 	google.golang.org/protobuf v1.28.1
 )
 
 require (
 	github.com/golang/protobuf v1.5.2 // indirect
-	golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
-	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
-	golang.org/x/text v0.3.7 // indirect
+	golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
+	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
+	golang.org/x/text v0.4.0 // indirect
 	google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
 )
diff --git a/api/v2/go.sum b/api/v2/go.sum
index 1e5f1846..156531c6 100644
--- a/api/v2/go.sum
+++ b/api/v2/go.sum
@@ -51,30 +51,36 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8=
-golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -85,20 +91,25 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -117,8 +128,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
 google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
-google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
+google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
diff --git a/connector/github/github.go b/connector/github/github.go
index 933b23ff..6cb0db09 100644
--- a/connector/github/github.go
+++ b/connector/github/github.go
@@ -39,16 +39,17 @@ var (
 
 // Config holds configuration options for github logins.
 type Config struct {
-	ClientID      string `json:"clientID"`
-	ClientSecret  string `json:"clientSecret"`
-	RedirectURI   string `json:"redirectURI"`
-	Org           string `json:"org"`
-	Orgs          []Org  `json:"orgs"`
-	HostName      string `json:"hostName"`
-	RootCA        string `json:"rootCA"`
-	TeamNameField string `json:"teamNameField"`
-	LoadAllGroups bool   `json:"loadAllGroups"`
-	UseLoginAsID  bool   `json:"useLoginAsID"`
+	ClientID             string `json:"clientID"`
+	ClientSecret         string `json:"clientSecret"`
+	RedirectURI          string `json:"redirectURI"`
+	Org                  string `json:"org"`
+	Orgs                 []Org  `json:"orgs"`
+	HostName             string `json:"hostName"`
+	RootCA               string `json:"rootCA"`
+	TeamNameField        string `json:"teamNameField"`
+	LoadAllGroups        bool   `json:"loadAllGroups"`
+	UseLoginAsID         bool   `json:"useLoginAsID"`
+	PreferredEmailDomain string `json:"preferredEmailDomain"`
 }
 
 // Org holds org-team filters, in which teams are optional.
@@ -75,14 +76,15 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error)
 	}
 
 	g := githubConnector{
-		redirectURI:  c.RedirectURI,
-		org:          c.Org,
-		orgs:         c.Orgs,
-		clientID:     c.ClientID,
-		clientSecret: c.ClientSecret,
-		apiURL:       apiURL,
-		logger:       logger,
-		useLoginAsID: c.UseLoginAsID,
+		redirectURI:          c.RedirectURI,
+		org:                  c.Org,
+		orgs:                 c.Orgs,
+		clientID:             c.ClientID,
+		clientSecret:         c.ClientSecret,
+		apiURL:               apiURL,
+		logger:               logger,
+		useLoginAsID:         c.UseLoginAsID,
+		preferredEmailDomain: c.PreferredEmailDomain,
 	}
 
 	if c.HostName != "" {
@@ -115,6 +117,12 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error)
 		return nil, fmt.Errorf("invalid connector config: unsupported team name field value `%s`", c.TeamNameField)
 	}
 
+	if c.PreferredEmailDomain != "" {
+		if strings.HasSuffix(c.PreferredEmailDomain, "*") {
+			return nil, errors.New("invalid PreferredEmailDomain: glob pattern cannot end with \"*\"")
+		}
+	}
+
 	return &g, nil
 }
 
@@ -149,6 +157,8 @@ type githubConnector struct {
 	loadAllGroups bool
 	// if set to true will use the user's handle rather than their numeric id as the ID
 	useLoginAsID bool
+	// the domain to be preferred among the user's emails. e.g. "github.com"
+	preferredEmailDomain string
 }
 
 // groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
@@ -548,7 +558,13 @@ type userEmail struct {
 // The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
 // which inserts a bearer token as part of the request.
 func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
+	var (
+		primaryEmail    userEmail
+		preferredEmails []userEmail
+	)
+
 	apiURL := c.apiURL + "/user/emails"
+
 	for {
 		// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
 		var (
@@ -575,7 +591,17 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s
 			}
 
 			if email.Verified && email.Primary {
-				return email.Email, nil
+				primaryEmail = email
+			}
+
+			if c.preferredEmailDomain != "" {
+				_, domainPart, ok := strings.Cut(email.Email, "@")
+				if !ok {
+					return "", errors.New("github: invalid format email is detected")
+				}
+				if email.Verified && c.isPreferredEmailDomain(domainPart) {
+					preferredEmails = append(preferredEmails, email)
+				}
 			}
 		}
 
@@ -584,7 +610,36 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s
 		}
 	}
 
-	return "", errors.New("github: user has no verified, primary email")
+	if len(preferredEmails) > 0 {
+		return preferredEmails[0].Email, nil
+	}
+
+	if primaryEmail.Email != "" {
+		return primaryEmail.Email, nil
+	}
+
+	return "", errors.New("github: user has no verified, primary email or preferred-domain email")
+}
+
+// isPreferredEmailDomain checks the domain is matching with preferredEmailDomain.
+func (c *githubConnector) isPreferredEmailDomain(domain string) bool {
+	if domain == c.preferredEmailDomain {
+		return true
+	}
+
+	preferredDomainParts := strings.Split(c.preferredEmailDomain, ".")
+	domainParts := strings.Split(domain, ".")
+
+	if len(preferredDomainParts) != len(domainParts) {
+		return false
+	}
+
+	for i, v := range preferredDomainParts {
+		if domainParts[i] != v && v != "*" {
+			return false
+		}
+	}
+	return true
 }
 
 // userInOrg queries the GitHub API for a users' org membership.
diff --git a/go.mod b/go.mod
index 8913c849..e51d9284 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/dexidp/dex
 go 1.19
 
 require (
-	entgo.io/ent v0.11.3
+	entgo.io/ent v0.11.4
 	github.com/AppsFlyer/go-sundheit v0.5.0
 	github.com/Masterminds/semver v1.5.0
 	github.com/Masterminds/sprig/v3 v3.2.3
@@ -32,16 +32,16 @@ require (
 	go.etcd.io/etcd/client/v3 v3.5.6
 	golang.org/x/crypto v0.4.0
 	golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741
-	golang.org/x/net v0.3.0
+	golang.org/x/net v0.4.0
 	golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783
-	google.golang.org/api v0.104.0
+	google.golang.org/api v0.105.0
 	google.golang.org/grpc v1.51.0
 	google.golang.org/protobuf v1.28.1
 	gopkg.in/square/go-jose.v2 v2.6.0
 )
 
 require (
-	ariga.io/atlas v0.7.2-0.20220927111110-867ee0cca56a // indirect
+	ariga.io/atlas v0.7.3-0.20221011160332-3ca609863edd // indirect
 	cloud.google.com/go/compute v1.13.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.2 // indirect
 	github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
diff --git a/go.sum b/go.sum
index 60f3e0a8..06fd194a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-ariga.io/atlas v0.7.2-0.20220927111110-867ee0cca56a h1:6/nt4DODfgxzHTTg3tYy7YkVzruGQGZ/kRvXpA45KUo=
-ariga.io/atlas v0.7.2-0.20220927111110-867ee0cca56a/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
+ariga.io/atlas v0.7.3-0.20221011160332-3ca609863edd h1:c3F2jvvEZzsoH/KUpDNhTsCVeUPnpXaF8kADZvUSiU0=
+ariga.io/atlas v0.7.3-0.20221011160332-3ca609863edd/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -62,8 +62,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-entgo.io/ent v0.11.3 h1:F5FBGAWiDCGder7YT+lqMnyzXl6d0xU3xMBM/SO3CMc=
-entgo.io/ent v0.11.3/go.mod h1:mvDhvynOzAsOe7anH7ynPPtMjA/eeXP96kAfweevyxc=
+entgo.io/ent v0.11.4 h1:grwVY0fp31BZ6oEo3YrXenAuv8VJmEw7F/Bi6WqeH3Q=
+entgo.io/ent v0.11.4/go.mod h1:fnQIXL36RYnCk/9nvG4aE7YHBFZhCycfh7wMjY5p7SE=
 github.com/AppsFlyer/go-sundheit v0.5.0 h1:/VxpyigCfJrq1r97mn9HPiAB2qrhcTFHwNIIDr15CZM=
 github.com/AppsFlyer/go-sundheit v0.5.0/go.mod h1:2ZM0BnfqT/mljBQO224VbL5XH06TgWuQ6Cn+cTtCpTY=
 github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU=
@@ -88,6 +88,7 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA=
 github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
 github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
@@ -531,8 +532,8 @@ golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.3.0 h1:VWL6FNY2bEEmsGVKabSlHu5Irp34xmMRoqb/9lF9lxk=
-golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
+golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -722,7 +723,6 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
 golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -762,8 +762,8 @@ google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69
 google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
 google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
 google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
-google.golang.org/api v0.104.0 h1:KBfmLRqdZEbwQleFlSLnzpQJwhjpmNOk4cKQIBDZ9mg=
-google.golang.org/api v0.104.0/go.mod h1:JCspTXJbBxa5ySXw4UgUqVer7DfVxbvc/CTUFqAED5U=
+google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
+google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
diff --git a/server/refreshhandlers.go b/server/refreshhandlers.go
index ecfda137..11eaf2e7 100644
--- a/server/refreshhandlers.go
+++ b/server/refreshhandlers.go
@@ -186,6 +186,8 @@ func (s *Server) refreshWithConnector(ctx context.Context, rCtx *refreshContext,
 	// TODO(ericchiang): We may want a strict mode where connectors that don't implement
 	// this interface can't perform refreshing.
 	if refreshConn, ok := rCtx.connector.Connector.(connector.RefreshConnector); ok {
+		// Set connector data to the one received from an offline session
+		ident.ConnectorData = rCtx.connectorData
 		s.logger.Debugf("connector data before refresh: %s", ident.ConnectorData)
 
 		newIdent, err := refreshConn.Refresh(ctx, parseScopes(rCtx.scopes), ident)
@@ -245,7 +247,6 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
 		Email:             rCtx.storageToken.Claims.Email,
 		EmailVerified:     rCtx.storageToken.Claims.EmailVerified,
 		Groups:            rCtx.storageToken.Claims.Groups,
-		ConnectorData:     rCtx.connectorData,
 	}
 
 	refreshTokenUpdater := func(old storage.RefreshToken) (storage.RefreshToken, error) {
@@ -255,6 +256,7 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
 		switch {
 		case !rotationEnabled && reusingAllowed:
 			// If rotation is disabled and the offline session was updated not so long ago - skip further actions.
+			old.ConnectorData = nil
 			return old, nil
 
 		case rotationEnabled && reusingAllowed:
@@ -269,7 +271,7 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
 
 			// Do not update last used time for offline session if token is allowed to be reused
 			lastUsed = old.LastUsed
-			ident.ConnectorData = nil
+			old.ConnectorData = nil
 			return old, nil
 
 		case rotationEnabled && !reusingAllowed:
@@ -286,7 +288,7 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
 		old.LastUsed = lastUsed
 
 		// ConnectorData has been moved to OfflineSession
-		old.ConnectorData = []byte{}
+		old.ConnectorData = nil
 
 		// Call  only once if there is a request which is not in the reuse interval.
 		// This is required to avoid multiple calls to the external IdP for concurrent requests.


Test patch:
diff --git a/connector/github/github_test.go b/connector/github/github_test.go
index 76d7463c..af8099e9 100644
--- a/connector/github/github_test.go
+++ b/connector/github/github_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/tls"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -198,6 +199,290 @@ func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
 	expectEquals(t, identity.Username, "Joe Bloggs")
 }
 
+func TestPreferredEmailDomainConfigured(t *testing.T) {
+	ctx := context.Background()
+	s := newTestServer(map[string]testResponse{
+		"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
+		"/user/emails": {
+			data: []userEmail{
+				{
+					Email:    "some@email.com",
+					Verified: true,
+					Primary:  true,
+				},
+				{
+					Email:    "another@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "some@preferred-domain.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "another@preferred-domain.com",
+					Verified: true,
+					Primary:  false,
+				},
+			},
+		},
+	})
+	defer s.Close()
+
+	hostURL, err := url.Parse(s.URL)
+	expectNil(t, err)
+
+	client := newClient()
+	c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "preferred-domain.com"}
+
+	u, err := c.user(ctx, client)
+	expectNil(t, err)
+	expectEquals(t, u.Email, "some@preferred-domain.com")
+}
+
+func TestPreferredEmailDomainConfiguredWithGlob(t *testing.T) {
+	ctx := context.Background()
+	s := newTestServer(map[string]testResponse{
+		"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
+		"/user/emails": {
+			data: []userEmail{
+				{
+					Email:    "some@email.com",
+					Verified: true,
+					Primary:  true,
+				},
+				{
+					Email:    "another@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "some@another.preferred-domain.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "some@sub-domain.preferred-domain.co",
+					Verified: true,
+					Primary:  false,
+				},
+			},
+		},
+	})
+	defer s.Close()
+
+	hostURL, err := url.Parse(s.URL)
+	expectNil(t, err)
+
+	client := newClient()
+	c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "*.preferred-domain.co"}
+
+	u, err := c.user(ctx, client)
+	expectNil(t, err)
+	expectEquals(t, u.Email, "some@sub-domain.preferred-domain.co")
+}
+
+func TestPreferredEmailDomainConfigured_UserHasNoPreferredDomainEmail(t *testing.T) {
+	ctx := context.Background()
+	s := newTestServer(map[string]testResponse{
+		"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
+		"/user/emails": {
+			data: []userEmail{
+				{
+					Email:    "some@email.com",
+					Verified: true,
+					Primary:  true,
+				},
+				{
+					Email:    "another@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+			},
+		},
+	})
+	defer s.Close()
+
+	hostURL, err := url.Parse(s.URL)
+	expectNil(t, err)
+
+	client := newClient()
+	c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "preferred-domain.com"}
+
+	u, err := c.user(ctx, client)
+	expectNil(t, err)
+	expectEquals(t, u.Email, "some@email.com")
+}
+
+func TestPreferredEmailDomainNotConfigured(t *testing.T) {
+	ctx := context.Background()
+	s := newTestServer(map[string]testResponse{
+		"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
+		"/user/emails": {
+			data: []userEmail{
+				{
+					Email:    "some@email.com",
+					Verified: true,
+					Primary:  true,
+				},
+				{
+					Email:    "another@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "some@preferred-domain.com",
+					Verified: true,
+					Primary:  false,
+				},
+			},
+		},
+	})
+	defer s.Close()
+
+	hostURL, err := url.Parse(s.URL)
+	expectNil(t, err)
+
+	client := newClient()
+	c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client}
+
+	u, err := c.user(ctx, client)
+	expectNil(t, err)
+	expectEquals(t, u.Email, "some@email.com")
+}
+
+func TestPreferredEmailDomainConfigured_Error_BothPrimaryAndPreferredDomainEmailNotFound(t *testing.T) {
+	ctx := context.Background()
+	s := newTestServer(map[string]testResponse{
+		"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
+		"/user/emails": {
+			data: []userEmail{
+				{
+					Email:    "some@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "another@email.com",
+					Verified: true,
+					Primary:  false,
+				},
+				{
+					Email:    "some@preferred-domain.com",
+					Verified: true,
+					Primary:  false,
+				},
+			},
+		},
+	})
+	defer s.Close()
+
+	hostURL, err := url.Parse(s.URL)
+	expectNil(t, err)
+
+	client := newClient()
+	c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: client, preferredEmailDomain: "foo.bar"}
+
+	_, err = c.user(ctx, client)
+	expectNotNil(t, err, "Email not found error")
+	expectEquals(t, err.Error(), "github: user has no verified, primary email or preferred-domain email")
+}
+
+func Test_isPreferredEmailDomain(t *testing.T) {
+	client := newClient()
+	tests := []struct {
+		preferredEmailDomain string
+		email                string
+		expected             bool
+	}{
+		{
+			preferredEmailDomain: "example.com",
+			email:                "test@example.com",
+			expected:             true,
+		},
+		{
+			preferredEmailDomain: "example.com",
+			email:                "test@another.com",
+			expected:             false,
+		},
+		{
+			preferredEmailDomain: "*.example.com",
+			email:                "test@my.example.com",
+			expected:             true,
+		},
+		{
+			preferredEmailDomain: "*.example.com",
+			email:                "test@my.another.com",
+			expected:             false,
+		},
+		{
+			preferredEmailDomain: "*.example.com",
+			email:                "test@my.domain.example.com",
+			expected:             false,
+		},
+		{
+			preferredEmailDomain: "*.example.com",
+			email:                "test@sub.domain.com",
+			expected:             false,
+		},
+		{
+			preferredEmailDomain: "*.*.example.com",
+			email:                "test@sub.my.example.com",
+			expected:             true,
+		},
+		{
+			preferredEmailDomain: "*.*.example.com",
+			email:                "test@a.my.google.com",
+			expected:             false,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.preferredEmailDomain, func(t *testing.T) {
+			c := githubConnector{apiURL: "apiURL", hostName: "github.com", httpClient: client, preferredEmailDomain: test.preferredEmailDomain}
+			_, domainPart, _ := strings.Cut(test.email, "@")
+			res := c.isPreferredEmailDomain(domainPart)
+
+			expectEquals(t, res, test.expected)
+		})
+	}
+}
+
+func Test_Open_PreferredDomainConfig(t *testing.T) {
+	tests := []struct {
+		preferredEmailDomain string
+		email                string
+		expected             error
+	}{
+		{
+			preferredEmailDomain: "example.com",
+			expected:             nil,
+		},
+		{
+			preferredEmailDomain: "*.example.com",
+			expected:             nil,
+		},
+		{
+			preferredEmailDomain: "*.*.example.com",
+			expected:             nil,
+		},
+		{
+			preferredEmailDomain: "example.*",
+			expected:             errors.New("invalid PreferredEmailDomain: glob pattern cannot end with \"*\""),
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.preferredEmailDomain, func(t *testing.T) {
+			c := Config{
+				PreferredEmailDomain: test.preferredEmailDomain,
+			}
+			_, err := c.Open("id", nil)
+
+			expectEquals(t, err, test.expected)
+		})
+	}
+}
+
 func newTestServer(responses map[string]testResponse) *httptest.Server {
 	var s *httptest.Server
 	s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -231,6 +516,12 @@ func expectNil(t *testing.T, a interface{}) {
 	}
 }
 
+func expectNotNil(t *testing.T, a interface{}, msg string) {
+	if a == nil {
+		t.Errorf("Expected %+v to not to be nil", msg)
+	}
+}
+
 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/server/server_test.go b/server/server_test.go
index 828e7faf..e54e80af 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -871,6 +871,17 @@ func TestOAuth2CodeFlow(t *testing.T) {
 			if respDump, err = httputil.DumpResponse(resp, true); err != nil {
 				t.Fatal(err)
 			}
+
+			tokens, err := s.storage.ListRefreshTokens()
+			if err != nil {
+				t.Fatalf("failed to get existed refresh token: %v", err)
+			}
+
+			for _, token := range tokens {
+				if /* token was updated */ token.ObsoleteToken != "" && token.ConnectorData != nil {
+					t.Fatalf("token connectorDatawith id %q field is not nil: %s", token.ID, token.ConnectorData)
+				}
+			}
 		})
 	}
 }