diff --git a/connector/github/github.go b/connector/github/github.go index 933b23ff31..6cb0db09df 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/connector/github/github_test.go b/connector/github/github_test.go index 76d7463cf6..af8099e9c7 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)