// Package github provides authentication strategies using GitHub. package github import ( "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io/ioutil" "net" "net/http" "regexp" "strconv" "strings" "time" "golang.org/x/oauth2" "golang.org/x/oauth2/github" "github.com/coreos/dex/connector" "github.com/sirupsen/logrus" ) const ( apiURL = "https://api.github.com" scopeEmail = "user:email" 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. 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"` } // Org holds org-team filters, in which teams are optional. type Org struct { // Organization name in github (not slug, full name). Only users in this github // organization can authenticate. Name string `json:"name"` // Names of teams in a github organization. A user will be able to // authenticate if they are members of at least one of these teams. Users // in the organization can authenticate if this field is omitted from the // config file. Teams []string `json:"teams,omitempty"` } // Open returns a strategy for logging in through GitHub. func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) { if c.Org != "" { // Return error if both 'org' and 'orgs' fields are used. if len(c.Orgs) > 0 { return nil, errors.New("github: cannot use both 'org' and 'orgs' fields simultaneously") } logger.Warnln("github: legacy field 'org' being used. Switch to the newer 'orgs' field structure") } g := githubConnector{ redirectURI: c.RedirectURI, org: c.Org, orgs: c.Orgs, clientID: c.ClientID, clientSecret: c.ClientSecret, apiURL: apiURL, logger: logger, } if c.HostName != "" { // ensure this is a hostname and not a URL or path. if strings.Contains(c.HostName, "/") { return nil, errors.New("invalid hostname: hostname cannot contain `/`") } g.hostName = c.HostName g.apiURL = "https://" + c.HostName + "/api/v3" } if c.RootCA != "" { if c.HostName == "" { return nil, errors.New("invalid connector config: Host name field required for a root certificate file") } g.rootCA = c.RootCA var err error if g.httpClient, err = newHTTPClient(g.rootCA); err != nil { return nil, fmt.Errorf("failed to create HTTP client: %v", err) } } return &g, nil } type connectorData struct { // GitHub's OAuth2 tokens never expire. We don't need a refresh token. AccessToken string `json:"accessToken"` } var ( _ connector.CallbackConnector = (*githubConnector)(nil) _ connector.RefreshConnector = (*githubConnector)(nil) ) type githubConnector struct { redirectURI string org string orgs []Org clientID string clientSecret string logger logrus.FieldLogger // apiURL defaults to "https://api.github.com" apiURL string // hostName of the GitHub enterprise account. hostName string // Used to support untrusted/self-signed CA certs. rootCA string // HTTP Client that trusts the custom delcared rootCA cert. httpClient *http.Client } func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { var githubScopes []string if scopes.Groups { githubScopes = []string{scopeEmail, scopeOrgs} } else { githubScopes = []string{scopeEmail} } endpoint := github.Endpoint // case when it is a GitHub Enterprise account. if c.hostName != "" { endpoint = oauth2.Endpoint{ AuthURL: "https://" + c.hostName + "/login/oauth/authorize", TokenURL: "https://" + c.hostName + "/login/oauth/access_token", } } return &oauth2.Config{ ClientID: c.clientID, ClientSecret: c.clientSecret, Endpoint: endpoint, Scopes: githubScopes, } } func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { if c.redirectURI != callbackURL { return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) } return c.oauth2Config(scopes).AuthCodeURL(state), nil } type oauth2Error struct { error string errorDescription string } func (e *oauth2Error) Error() string { if e.errorDescription == "" { return e.error } return e.error + ": " + e.errorDescription } // newHTTPClient returns a new HTTP client that trusts the custom delcared rootCA cert. func newHTTPClient(rootCA string) (*http.Client, error) { tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} rootCABytes, err := ioutil.ReadFile(rootCA) if err != nil { return nil, fmt.Errorf("failed to read root-ca: %v", err) } if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) { return nil, fmt.Errorf("no certs found in root CA file %q", rootCA) } return &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tlsConfig, Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, }, nil } func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { return identity, &oauth2Error{errType, q.Get("error_description")} } oauth2Config := c.oauth2Config(s) ctx := r.Context() // GitHub Enterprise account if c.httpClient != nil { ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) } token, err := oauth2Config.Exchange(ctx, q.Get("code")) if err != nil { return identity, fmt.Errorf("github: failed to get token: %v", err) } client := oauth2Config.Client(ctx, token) user, err := c.user(ctx, client) if err != nil { return identity, fmt.Errorf("github: get user: %v", err) } username := user.Name if username == "" { username = user.Login } identity = connector.Identity{ UserID: strconv.Itoa(user.ID), Username: username, Email: user.Email, EmailVerified: true, } if s.Groups { var groups []string if len(c.orgs) > 0 { if groups, err = c.listGroups(ctx, client, user.Login); err != nil { return identity, err } } else if c.org != "" { inOrg, err := c.userInOrg(ctx, client, user.Login, c.org) if err != nil { return identity, err } if !inOrg { return identity, fmt.Errorf("github: user %q not a member of org %q", user.Login, c.org) } if groups, err = c.teams(ctx, client, c.org); err != nil { return identity, fmt.Errorf("github: get teams: %v", err) } } identity.Groups = groups } if s.OfflineAccess { data := connectorData{AccessToken: token.AccessToken} connData, err := json.Marshal(data) if err != nil { return identity, fmt.Errorf("marshal connector data: %v", err) } identity.ConnectorData = connData } return identity, nil } func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { if len(identity.ConnectorData) == 0 { return identity, errors.New("no upstream access token found") } var data connectorData if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { return identity, fmt.Errorf("github: unmarshal access token: %v", err) } client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken}) user, err := c.user(ctx, client) if err != nil { return identity, fmt.Errorf("github: get user: %v", err) } username := user.Name if username == "" { username = user.Login } identity.Username = username identity.Email = user.Email if s.Groups { var groups []string if len(c.orgs) > 0 { if groups, err = c.listGroups(ctx, client, user.Login); err != nil { return identity, err } } else if c.org != "" { inOrg, err := c.userInOrg(ctx, client, user.Login, c.org) if err != nil { return identity, err } if !inOrg { return identity, fmt.Errorf("github: user %q not a member of org %q", user.Login, c.org) } if groups, err = c.teams(ctx, client, c.org); err != nil { return identity, fmt.Errorf("github: get teams: %v", err) } } identity.Groups = groups } return identity, nil } // listGroups enforces org and team constraints on user authorization // Cases in which user is authorized: // N orgs, no teams: user is member of at least 1 org // N orgs, M teams per org: user is member of any team from at least 1 org // N-1 orgs, M teams per org, 1 org with no teams: user is member of any team // from at least 1 org, or member of org with no teams func (c *githubConnector) listGroups(ctx context.Context, client *http.Client, userName string) (groups []string, err error) { var inOrgNoTeams bool for _, org := range c.orgs { inOrg, err := c.userInOrg(ctx, client, userName, org.Name) if err != nil { return groups, err } if !inOrg { continue } teams, err := c.teams(ctx, client, org.Name) if err != nil { return groups, err } // User is in at least one org. User is authorized if no teams are specified // in config; include all teams in claim. Otherwise filter out teams not in // 'teams' list in config. if len(org.Teams) == 0 { inOrgNoTeams = true } else if teams = filterTeams(teams, org.Teams); len(teams) == 0 { c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name) } // Orgs might have the same team names. We append orgPrefix to team name, // i.e. "org:team", to make team names unique across orgs. orgPrefix := org.Name + ":" for _, teamName := range teams { groups = append(groups, orgPrefix+teamName) } } if inOrgNoTeams || len(groups) > 0 { return } return groups, fmt.Errorf("github: user %q not in required orgs or teams", userName) } // Filter the users' team memberships by 'teams' from config. func filterTeams(userTeams, configTeams []string) (teams []string) { teamFilter := make(map[string]struct{}) for _, team := range configTeams { if _, ok := teamFilter[team]; !ok { teamFilter[team] = struct{}{} } } for _, team := range userTeams { if _, ok := teamFilter[team]; ok { teams = append(teams, team) } } 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 { Name string `json:"name"` Login string `json:"login"` ID int `json:"id"` Email string `json:"email"` } // user queries the GitHub API for profile information using the provided client. // // 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) { // https://developer.github.com/v3/users/#get-the-authenticated-user var u user if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil { return u, err } // Only pulic 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 } } 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. // // 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. func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, userName, orgName string) (bool, error) { // requester == user, so GET-ing this endpoint should return 404/302 if user // is not a member // // https://developer.github.com/v3/orgs/members/#check-membership apiURL := fmt.Sprintf("%s/orgs/%s/members/%s", c.apiURL, orgName, userName) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return false, fmt.Errorf("github: new req: %v", err) } req = req.WithContext(ctx) resp, err := client.Do(req) if err != nil { return false, fmt.Errorf("github: get teams: %v", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusNoContent: case http.StatusFound, http.StatusNotFound: c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName) default: err = fmt.Errorf("github: unexpected return status: %q", resp.Status) } // 204 if user is a member 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. // // 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. func (c *githubConnector) teams(ctx context.Context, client *http.Client, orgName string) ([]string, error) { apiURL, groups := c.apiURL+"/user/teams", []string{} for { // https://developer.github.com/v3/orgs/teams/#list-user-teams var ( teams []team err error ) if apiURL, err = get(ctx, client, apiURL, &teams); err != nil { return nil, err } for _, team := range teams { if team.Org.Login == orgName { groups = append(groups, team.Name) } } if apiURL == "" { break } } return groups, nil }