diff --git a/Documentation/github-connector.md b/Documentation/github-connector.md index 223853bf..f0f76403 100644 --- a/Documentation/github-connector.md +++ b/Documentation/github-connector.md @@ -31,4 +31,37 @@ connectors: org: my-oranization ``` +## GitHub Enterprise + +Users can use their GitHub Enterprise account to login to dex. The following configuration can be used to enable a GitHub Enterprise connector on dex: + +```yaml +connectors: +- type: github + # Required field for connector id. + id: github + # Required field for connector name. + name: GitHub + config: + # Required fields. Dex must be pre-registered with GitHub Enterprise + # to get the following values. + # Credentials can be string literals or pulled from the environment. + clientID: $GITHUB_CLIENT_ID + clientSecret: $GITHUB_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + # Optional organization to pull teams from, communicate through the + # "groups" scope. + # + # NOTE: This is an EXPERIMENTAL config option and will likely change. + org: my-oranization + + # Required ONLY for GitHub Enterprise. + # This is the Hostname of the GitHub Enterprise account listed on the + # management console. Ensure this domain is routable on your network. + hostName: git.example.com + # ONLY for GitHub Enterprise. Optional field. + # Used to support self-signed or untrusted CA root certificates. + rootCA: /etc/dex/ca.crt +``` + [github-oauth2]: https://github.com/settings/applications/new diff --git a/connector/github/github.go b/connector/github/github.go index 49dc3bb3..4b03b188 100644 --- a/connector/github/github.go +++ b/connector/github/github.go @@ -3,13 +3,18 @@ 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" @@ -19,7 +24,7 @@ import ( ) const ( - baseURL = "https://api.github.com" + apiURL = "https://api.github.com" scopeEmail = "user:email" scopeOrgs = "read:org" ) @@ -30,17 +35,45 @@ type Config struct { ClientSecret string `json:"clientSecret"` RedirectURI string `json:"redirectURI"` Org string `json:"org"` + HostName string `json:"hostName"` + RootCA string `json:"rootCA"` } // Open returns a strategy for logging in through GitHub. func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) { - return &githubConnector{ + g := githubConnector{ redirectURI: c.RedirectURI, org: c.Org, clientID: c.ClientID, clientSecret: c.ClientSecret, + apiURL: apiURL, logger: logger, - }, nil + } + + 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 { @@ -59,6 +92,14 @@ type githubConnector struct { 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 { @@ -68,10 +109,21 @@ func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { } 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: github.Endpoint, + Endpoint: endpoint, Scopes: githubScopes, } } @@ -80,6 +132,7 @@ func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state s if c.redirectURI != callbackURL { return "", fmt.Errorf("expected callback URL did not match the URL in the config") } + return c.oauth2Config(scopes).AuthCodeURL(state), nil } @@ -95,6 +148,34 @@ func (e *oauth2Error) Error() string { 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 != "" { @@ -102,7 +183,12 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i } 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 { @@ -192,7 +278,7 @@ type user struct { // a bearer token as part of the request. func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) { var u user - req, err := http.NewRequest("GET", baseURL+"/user", nil) + req, err := http.NewRequest("GET", c.apiURL+"/user", nil) if err != nil { return u, fmt.Errorf("github: new req: %v", err) } @@ -228,7 +314,7 @@ func (c *githubConnector) teams(ctx context.Context, client *http.Client, org st // https://developer.github.com/v3/#pagination reNext := regexp.MustCompile("<(.*)>; rel=\"next\"") reLast := regexp.MustCompile("<(.*)>; rel=\"last\"") - apiURL := baseURL + "/user/teams" + apiURL := c.apiURL + "/user/teams" for { req, err := http.NewRequest("GET", apiURL, nil)