connector/github: enable private, primary emails; refactor API calls
Documentation: removed private emails caveats section
This commit is contained in:
parent
45bf061236
commit
26527011ab
2 changed files with 139 additions and 75 deletions
|
@ -6,10 +6,6 @@ One of the login options for dex uses the GitHub OAuth2 flow to identify the end
|
||||||
|
|
||||||
When a client redeems a refresh token through dex, dex will re-query GitHub to update user information in the ID Token. To do this, __dex stores a readonly GitHub access token in its backing datastore.__ Users that reject dex's access through GitHub will also revoke all dex clients which authenticated them through GitHub.
|
When a client redeems a refresh token through dex, dex will re-query GitHub to update user information in the ID Token. To do this, __dex stores a readonly GitHub access token in its backing datastore.__ Users that reject dex's access through GitHub will also revoke all dex clients which authenticated them through GitHub.
|
||||||
|
|
||||||
## Caveats
|
|
||||||
|
|
||||||
* Please note that in order for a user to be authenticated via GitHub, the user needs to mark their email id as public on GitHub. This will enable the API to return the user's email to Dex.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Register a new application with [GitHub][github-oauth2] ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
|
Register a new application with [GitHub][github-oauth2] ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
|
||||||
|
|
|
@ -29,6 +29,11 @@ const (
|
||||||
scopeOrgs = "read:org"
|
scopeOrgs = "read:org"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pagination URL patterns
|
||||||
|
// https://developer.github.com/v3/#pagination
|
||||||
|
var reNext = regexp.MustCompile("<([^>]+)>; rel=\"next\"")
|
||||||
|
var reLast = regexp.MustCompile("<([^>]+)>; rel=\"last\"")
|
||||||
|
|
||||||
// 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"`
|
||||||
|
@ -380,6 +385,67 @@ func filterTeams(userTeams, configTeams []string) (teams []string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get creates a "GET `apiURL`" request with context, sends the request using
|
||||||
|
// the client, and decodes the resulting response body into v. A pagination URL
|
||||||
|
// is returned if one exists. Any errors encountered when building requests,
|
||||||
|
// sending requests, and reading and decoding response data are returned.
|
||||||
|
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("github: new req: %v", err)
|
||||||
|
}
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("github: get URL %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("github: read body: %v", err)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s: %s", resp.Status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPagination(apiURL, resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPagination checks the "Link" header field for "next" or "last" pagination
|
||||||
|
// URLs, and returns true only if a "next" URL is found. The next pages' URL is
|
||||||
|
// returned if a "next" URL is found. apiURL is returned if apiURL equals the
|
||||||
|
// "last" URL or no "next" or "last" URL are found.
|
||||||
|
//
|
||||||
|
// https://developer.github.com/v3/#pagination
|
||||||
|
func getPagination(apiURL string, resp *http.Response) string {
|
||||||
|
if resp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
links := resp.Header.Get("Link")
|
||||||
|
if len(reLast.FindStringSubmatch(links)) > 1 {
|
||||||
|
lastPageURL := reLast.FindStringSubmatch(links)[1]
|
||||||
|
if apiURL == lastPageURL {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reNext.FindStringSubmatch(links)) > 1 {
|
||||||
|
return reNext.FindStringSubmatch(links)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// user holds GitHub user information (relevant to dex) as defined by
|
||||||
|
// https://developer.github.com/v3/users/#response-with-public-profile-information
|
||||||
type user struct {
|
type user struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
|
@ -387,36 +453,69 @@ type user struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// user queries the GitHub API for profile information using the provided client. The HTTP
|
// user queries the GitHub API for profile information using the provided client.
|
||||||
// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts
|
//
|
||||||
// a bearer token as part of the request.
|
// 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) user(ctx context.Context, client *http.Client) (user, error) {
|
func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) {
|
||||||
|
// https://developer.github.com/v3/users/#get-the-authenticated-user
|
||||||
var u user
|
var u user
|
||||||
req, err := http.NewRequest("GET", c.apiURL+"/user", nil)
|
if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil {
|
||||||
if err != nil {
|
return u, err
|
||||||
return u, fmt.Errorf("github: new req: %v", err)
|
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return u, fmt.Errorf("github: get URL %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
// Only pulic user emails are returned by 'GET /user'. u.Email will be empty
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
// if a users' email is private. We must retrieve private emails explicitly.
|
||||||
if err != nil {
|
if u.Email == "" {
|
||||||
return u, fmt.Errorf("github: read body: %v", err)
|
var err error
|
||||||
|
if u.Email, err = c.userEmail(ctx, client); err != nil {
|
||||||
|
return u, err
|
||||||
}
|
}
|
||||||
return u, fmt.Errorf("%s: %s", resp.Status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
|
||||||
return u, fmt.Errorf("failed to decode response: %v", err)
|
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userEmail holds GitHub user email information as defined by
|
||||||
|
// https://developer.github.com/v3/users/emails/#response
|
||||||
|
type userEmail struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userEmail queries the GitHub API for a users' email information using the
|
||||||
|
// provided client. Only returns the users' verified, primary email (private or
|
||||||
|
// public).
|
||||||
|
//
|
||||||
|
// 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) {
|
||||||
|
apiURL := c.apiURL + "/user/emails"
|
||||||
|
for {
|
||||||
|
// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
|
||||||
|
var (
|
||||||
|
emails []userEmail
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if apiURL, err = get(ctx, client, apiURL, &emails); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, email := range emails {
|
||||||
|
if email.Verified && email.Primary {
|
||||||
|
return email.Email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiURL == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("github: user has no verified, primary email")
|
||||||
|
}
|
||||||
|
|
||||||
// userInOrg queries the GitHub API for a users' org membership.
|
// userInOrg queries the GitHub API for a users' org membership.
|
||||||
//
|
//
|
||||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||||
|
@ -452,49 +551,29 @@ func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, us
|
||||||
return resp.StatusCode == http.StatusNoContent, err
|
return resp.StatusCode == http.StatusNoContent, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// teams holds GitHub a users' team information as defined by
|
||||||
|
// https://developer.github.com/v3/orgs/teams/#response-12
|
||||||
|
type team struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Org struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"organization"`
|
||||||
|
}
|
||||||
|
|
||||||
// teams queries the GitHub API for team membership within a specific organization.
|
// teams queries the GitHub API for team membership within a specific organization.
|
||||||
//
|
//
|
||||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
// The HTTP passed 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) teams(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
|
func (c *githubConnector) teams(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
|
||||||
|
apiURL, groups := c.apiURL+"/user/teams", []string{}
|
||||||
groups := []string{}
|
|
||||||
|
|
||||||
// https://developer.github.com/v3/#pagination
|
|
||||||
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
|
|
||||||
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
|
|
||||||
apiURL := c.apiURL + "/user/teams"
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
// https://developer.github.com/v3/orgs/teams/#list-user-teams
|
||||||
|
var (
|
||||||
if err != nil {
|
teams []team
|
||||||
return nil, fmt.Errorf("github: new req: %v", err)
|
err error
|
||||||
}
|
)
|
||||||
req = req.WithContext(ctx)
|
if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
|
||||||
resp, err := client.Do(req)
|
return nil, err
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("github: get teams: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("github: read body: %v", err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://developer.github.com/v3/orgs/teams/#response-12
|
|
||||||
var teams []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Org struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
} `json:"organization"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
|
|
||||||
return nil, fmt.Errorf("github: unmarshal groups: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, team := range teams {
|
for _, team := range teams {
|
||||||
|
@ -503,21 +582,10 @@ func (c *githubConnector) teams(ctx context.Context, client *http.Client, orgNam
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
links := resp.Header.Get("Link")
|
if apiURL == "" {
|
||||||
if len(reLast.FindStringSubmatch(links)) > 1 {
|
|
||||||
lastPageURL := reLast.FindStringSubmatch(links)[1]
|
|
||||||
if apiURL == lastPageURL {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(reNext.FindStringSubmatch(links)) > 1 {
|
|
||||||
apiURL = reNext.FindStringSubmatch(links)[1]
|
|
||||||
} else {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups, nil
|
return groups, nil
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue