From 1904321929628fce06c27407187f1f778aa6915f Mon Sep 17 00:00:00 2001 From: Billy Lynch Date: Wed, 3 Aug 2022 13:19:02 -0400 Subject: [PATCH] github: github: Use noreply email if public email is private. GitHub has a feature for commit emails that allows users to set a noreply email that uniquely identifies them in commit messages: ``` {id}+{login}@users.noreply.github.com ``` See https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#about-commit-email-addresses This is typically used on GitHub to associated user accounts with commits without exposing the users real email. Dex can use this in a similar manner to ID users in a stable way without needing to fetch emails marked as private on GitHub. This change adds an option to Dex to return these emails instead of fetching their primary email. Since this only appears to work for public and Enterprise Cloud flavors of GitHub (notably this doesn't appear to be supported on Enterprise Server), this is restricted to the github.com domain for now. Signed-off-by: Billy Lynch --- connector/github/github.go | 26 +++++++++++++++++--- connector/github/github_test.go | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/connector/github/github.go b/connector/github/github.go index ef8d418fa8..1ff2cb5f13 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -53,6 +53,13 @@ type Config struct { TeamNameField string `json:"teamNameField"` LoadAllGroups bool `json:"loadAllGroups"` UseLoginAsID bool `json:"useLoginAsID"` + // NoreplyPrivateEmail configures the connector to use + // {id}+{login}@users.noreply.github.com as the user email if user has + // marked their email as private on GitHub. + // See https://docs.github.com/en/enterprise-cloud@latest/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#setting-your-commit-email-address-on-github. + // Note, this is only valid for public and Enterprise Cloud GitHub (i.e. this only works on github.com domains). + // There is no equivalent for Enterprise Server GitHub / custom hosts. + NoreplyPrivateEmail bool `json:"noreplyPrivateEmail"` } // Org holds org-team filters, in which teams are optional. @@ -153,6 +160,12 @@ 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 + // use {id}+{login}@users.noreply.github.com as the user email if user has + // marked their email as private on GitHub. + // See https://docs.github.com/en/enterprise-cloud@latest/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#setting-your-commit-email-address-on-github. + // Note, this is only valid for public and Enterprise Cloud GitHub (i.e. this only works on github.com domains). + // There is no equivalent for Enterprise Server GitHub / custom hosts. + noreplyPrivateEmail bool } // groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex @@ -554,9 +567,16 @@ func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, // Only public user emails are returned by 'GET /user'. u.Email will be empty // if a users' email is private. We must retrieve private emails explicitly. if u.Email == "" { - var err error - if u.Email, err = c.userEmail(ctx, client); err != nil { - return u, err + // If on github.com, GitHub allows for a special noreply email to + // associate users to commits without exposing their private email. + // See https://docs.github.com/en/enterprise-cloud@latest/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#about-commit-email-addresses + if c.noreplyPrivateEmail && (c.hostName == "" || c.hostName == "github.com") { + u.Email = fmt.Sprintf("%d+%s@users.noreply.github.com", u.ID, u.Login) + } else { + var err error + if u.Email, err = c.userEmail(ctx, client); err != nil { + return u, err + } } } return u, nil diff --git a/connector/github/github_test.go b/connector/github/github_test.go index 76d7463cf6..8153090dcf 100644 --- a/connector/github/github_test.go +++ b/connector/github/github_test.go @@ -236,3 +236,45 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %+v to equal %+v", a, b) } } + +func TestNoreplyUserEmail(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, + }}}, + }) + defer s.Close() + client := newClient() + + for _, tc := range []struct { + host string + want string + }{ + { + want: "12345678+some-login@users.noreply.github.com", + }, + { + host: "github.com", + want: "12345678+some-login@users.noreply.github.com", + }, + { + host: "example.com", + want: "some@email.com", + }, + } { + t.Run(tc.host, func(t *testing.T) { + c := githubConnector{apiURL: s.URL, hostName: tc.host, httpClient: client, noreplyPrivateEmail: true} + u, err := c.user(ctx, client) + if err != nil { + t.Fatal(err) + } + if u.Email != tc.want { + t.Errorf("want %s, got %s", tc.want, u.Email) + } + }) + } +}