Skip to content
Snippets Groups Projects
Unverified Commit c91b87fa authored by Nobuo Takizawa's avatar Nobuo Takizawa Committed by GitHub
Browse files

Add preferredEmailDomain config option for GitHub connector (#2740)

parent 263526a3
Branches
Tags
No related merge requests found
...@@ -39,16 +39,17 @@ var ( ...@@ -39,16 +39,17 @@ var (
// Config holds configuration options for github logins. // Config holds configuration options for github logins.
type Config struct { type Config struct {
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"` RedirectURI string `json:"redirectURI"`
Org string `json:"org"` Org string `json:"org"`
Orgs []Org `json:"orgs"` Orgs []Org `json:"orgs"`
HostName string `json:"hostName"` HostName string `json:"hostName"`
RootCA string `json:"rootCA"` RootCA string `json:"rootCA"`
TeamNameField string `json:"teamNameField"` TeamNameField string `json:"teamNameField"`
LoadAllGroups bool `json:"loadAllGroups"` LoadAllGroups bool `json:"loadAllGroups"`
UseLoginAsID bool `json:"useLoginAsID"` UseLoginAsID bool `json:"useLoginAsID"`
PreferredEmailDomain string `json:"preferredEmailDomain"`
} }
// Org holds org-team filters, in which teams are optional. // 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) ...@@ -75,14 +76,15 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error)
} }
g := githubConnector{ g := githubConnector{
redirectURI: c.RedirectURI, redirectURI: c.RedirectURI,
org: c.Org, org: c.Org,
orgs: c.Orgs, orgs: c.Orgs,
clientID: c.ClientID, clientID: c.ClientID,
clientSecret: c.ClientSecret, clientSecret: c.ClientSecret,
apiURL: apiURL, apiURL: apiURL,
logger: logger, logger: logger,
useLoginAsID: c.UseLoginAsID, useLoginAsID: c.UseLoginAsID,
preferredEmailDomain: c.PreferredEmailDomain,
} }
if c.HostName != "" { if c.HostName != "" {
...@@ -115,6 +117,12 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) ...@@ -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) 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 return &g, nil
} }
...@@ -149,6 +157,8 @@ type githubConnector struct { ...@@ -149,6 +157,8 @@ type githubConnector struct {
loadAllGroups bool loadAllGroups bool
// if set to true will use the user's handle rather than their numeric id as the ID // if set to true will use the user's handle rather than their numeric id as the ID
useLoginAsID bool 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 // groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
...@@ -548,7 +558,13 @@ type userEmail struct { ...@@ -548,7 +558,13 @@ type userEmail struct {
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package, // 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. // which inserts a bearer token as part of the request.
func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) { func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
var (
primaryEmail userEmail
preferredEmails []userEmail
)
apiURL := c.apiURL + "/user/emails" apiURL := c.apiURL + "/user/emails"
for { for {
// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user // https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
var ( var (
...@@ -575,7 +591,17 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s ...@@ -575,7 +591,17 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s
} }
if email.Verified && email.Primary { 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 ...@@ -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. // userInOrg queries the GitHub API for a users' org membership.
......
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
...@@ -198,6 +199,290 @@ func TestLoginUsedAsIDWhenConfigured(t *testing.T) { ...@@ -198,6 +199,290 @@ func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
expectEquals(t, identity.Username, "Joe Bloggs") 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 { func newTestServer(responses map[string]testResponse) *httptest.Server {
var s *httptest.Server var s *httptest.Server
s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
...@@ -231,6 +516,12 @@ func expectNil(t *testing.T, a interface{}) { ...@@ -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{}) { func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) { if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %+v to equal %+v", a, b) t.Errorf("Expected %+v to equal %+v", a, b)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment