diff --git a/connector/connector_github.go b/connector/connector_github.go new file mode 100644 index 00000000..0ebb40a7 --- /dev/null +++ b/connector/connector_github.go @@ -0,0 +1,145 @@ +package connector + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "path" + "strconv" + + chttp "github.com/coreos/go-oidc/http" + "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" +) + +const ( + GitHubConnectorType = "github" + githubAuthURL = "https://github.com/login/oauth/authorize" + githubTokenURL = "https://github.com/login/oauth/access_token" + githubAPIUserURL = "https://api.github.com/user" +) + +func init() { + RegisterConnectorConfigType(GitHubConnectorType, func() ConnectorConfig { return &GitHubConnectorConfig{} }) +} + +type GitHubConnectorConfig struct { + ID string `json:"id"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` +} + +func (cfg *GitHubConnectorConfig) ConnectorID() string { + return cfg.ID +} + +func (cfg *GitHubConnectorConfig) ConnectorType() string { + return GitHubConnectorType +} + +func (cfg *GitHubConnectorConfig) Connector(ns url.URL, lf oidc.LoginFunc, tpls *template.Template) (Connector, error) { + ns.Path = path.Join(ns.Path, httpPathCallback) + oauth2Conn, err := newGitHubConnector(cfg.ClientID, cfg.ClientSecret, ns.String()) + if err != nil { + return nil, err + } + return &OAuth2Connector{ + id: cfg.ID, + loginFunc: lf, + cbURL: ns, + conn: oauth2Conn, + }, nil +} + +type githubOAuth2Connector struct { + clientID string + clientSecret string + client *oauth2.Client +} + +func newGitHubConnector(clientID, clientSecret, cbURL string) (oauth2Connector, error) { + config := oauth2.Config{ + Credentials: oauth2.ClientCredentials{ID: clientID, Secret: clientSecret}, + AuthURL: githubAuthURL, + TokenURL: githubTokenURL, + Scope: []string{"user:email"}, + AuthMethod: oauth2.AuthMethodClientSecretPost, + RedirectURL: cbURL, + } + + cli, err := oauth2.NewClient(http.DefaultClient, config) + if err != nil { + return nil, err + } + + return &githubOAuth2Connector{ + clientID: clientID, + clientSecret: clientSecret, + client: cli, + }, nil +} + +// standard error form returned by github +type githubError struct { + Message string `json:"message"` +} + +func (err githubError) Error() string { + return fmt.Sprintf("github: %s", err.Message) +} + +func (c *githubOAuth2Connector) Client() *oauth2.Client { + return c.client +} + +func (c *githubOAuth2Connector) Identity(cli chttp.Client) (oidc.Identity, error) { + req, err := http.NewRequest("GET", githubAPIUserURL, nil) + if err != nil { + return oidc.Identity{}, err + } + resp, err := cli.Do(req) + if err != nil { + return oidc.Identity{}, fmt.Errorf("get: %v", err) + } + defer resp.Body.Close() + switch { + case resp.StatusCode >= 400 && resp.StatusCode < 600: + // attempt to decode error from github + var authErr githubError + if err := json.NewDecoder(resp.Body).Decode(&authErr); err != nil { + return oidc.Identity{}, oauth2.NewError(oauth2.ErrorAccessDenied) + } + return oidc.Identity{}, authErr + case resp.StatusCode == http.StatusOK: + default: + return oidc.Identity{}, fmt.Errorf("unexpected status from providor %s", resp.Status) + } + var user struct { + Login string `json:"login"` + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return oidc.Identity{}, fmt.Errorf("getting user info: %v", err) + } + name := user.Name + if name == "" { + name = user.Login + } + return oidc.Identity{ + ID: strconv.FormatInt(user.ID, 10), + Name: name, + Email: user.Email, + }, nil +} + +func (c *githubOAuth2Connector) Healthy() error { + return nil +} + +func (c *githubOAuth2Connector) TrustedEmailProvider() bool { + return false +} diff --git a/connector/connector_github_test.go b/connector/connector_github_test.go new file mode 100644 index 00000000..e6727fa5 --- /dev/null +++ b/connector/connector_github_test.go @@ -0,0 +1,41 @@ +package connector + +import ( + "net/http" + "testing" + + "github.com/coreos/go-oidc/oidc" +) + +var ( + githubExampleUser = `{"login":"octocat","id":1,"name": "monalisa octocat","email": "octocat@github.com"}` + githubExampleError = `{"message":"Bad credentials","documentation_url":"https://developer.github.com/v3"}` +) + +func TestGitHubIdentity(t *testing.T) { + tests := []oauth2IdentityTest{ + { + urlResps: map[string]response{ + githubAPIUserURL: {http.StatusOK, githubExampleUser}, + }, + want: oidc.Identity{ + Name: "monalisa octocat", + ID: "1", + Email: "octocat@github.com", + }, + }, + { + urlResps: map[string]response{ + githubAPIUserURL: {http.StatusUnauthorized, githubExampleError}, + }, + wantErr: githubError{ + Message: "Bad credentials", + }, + }, + } + conn, err := newGitHubConnector("fakeclientid", "fakeclientsecret", "http://examle.com/auth/github/callback") + if err != nil { + t.Fatal(err) + } + runOAuth2IdentityTests(t, conn, tests) +} diff --git a/connector/connector_oauth2.go b/connector/connector_oauth2.go new file mode 100644 index 00000000..4bc5f8ba --- /dev/null +++ b/connector/connector_oauth2.go @@ -0,0 +1,140 @@ +package connector + +import ( + "net/http" + "net/url" + "strings" + + "github.com/coreos/dex/pkg/log" + chttp "github.com/coreos/go-oidc/http" + "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" +) + +type oauth2Connector interface { + Client() *oauth2.Client + + // Identity uses a HTTP client authenticated as the end user to construct + // an OIDC identity for that user. + Identity(cli chttp.Client) (oidc.Identity, error) + + // Healthy it should attempt to determine if the connector's credientials + // are valid. + Healthy() error + + TrustedEmailProvider() bool +} + +type OAuth2Connector struct { + id string + loginFunc oidc.LoginFunc + cbURL url.URL + conn oauth2Connector +} + +func (c *OAuth2Connector) ID() string { + return c.id +} + +func (c *OAuth2Connector) Healthy() error { + return c.conn.Healthy() +} + +func (c *OAuth2Connector) Sync() chan struct{} { + stop := make(chan struct{}, 1) + return stop +} + +func (c *OAuth2Connector) TrustedEmailProvider() bool { + return c.conn.TrustedEmailProvider() +} + +func (c *OAuth2Connector) LoginURL(sessionKey, prompt string) (string, error) { + return c.conn.Client().AuthCodeURL(sessionKey, oauth2.GrantTypeAuthCode, prompt), nil +} + +func (c *OAuth2Connector) Register(mux *http.ServeMux, errorURL url.URL) { + mux.Handle(c.cbURL.Path, c.handleCallbackFunc(c.loginFunc, errorURL)) +} + +func (c *OAuth2Connector) handleCallbackFunc(lf oidc.LoginFunc, errorURL url.URL) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + e := q.Get("error") + if e != "" { + redirectError(w, errorURL, q) + return + } + + code := q.Get("code") + if code == "" { + q.Set("error", oauth2.ErrorInvalidRequest) + q.Set("error_description", "code query param must be set") + redirectError(w, errorURL, q) + return + } + sessionKey := q.Get("state") + + token, err := c.conn.Client().RequestToken(oauth2.GrantTypeAuthCode, code) + if err != nil { + log.Errorf("Unable to verify auth code with issuer: %v", err) + q.Set("error", oauth2.ErrorUnsupportedResponseType) + q.Set("error_description", "unable to verify auth code with issuer") + redirectError(w, errorURL, q) + return + } + ident, err := c.conn.Identity(newAuthenticatedClient(token, http.DefaultClient)) + if err != nil { + log.Errorf("Unable to retrieve identity: %v", err) + q.Set("error", oauth2.ErrorUnsupportedResponseType) + q.Set("error_description", "unable to retrieve identity from issuer") + redirectError(w, errorURL, q) + return + } + redirectURL, err := lf(ident, sessionKey) + if err != nil { + log.Errorf("Unable to log in %#v: %v", ident, err) + q.Set("error", oauth2.ErrorAccessDenied) + q.Set("error_description", "login failed") + redirectError(w, errorURL, q) + return + } + w.Header().Set("Location", redirectURL) + w.WriteHeader(http.StatusTemporaryRedirect) + return + } +} + +// authedClient authenticates all requests as the end user. +type authedClient struct { + token oauth2.TokenResponse + cli chttp.Client +} + +func newAuthenticatedClient(token oauth2.TokenResponse, cli chttp.Client) chttp.Client { + return &authedClient{token, cli} +} + +func (c *authedClient) Do(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", tokenType(c.token)+" "+c.token.AccessToken) + return c.cli.Do(req) +} + +// Return the canonical name of the token type if non-empty, else "Bearer". +// Take from golang.org/x/oauth2 +func tokenType(token oauth2.TokenResponse) string { + if strings.EqualFold(token.TokenType, "bearer") { + return "Bearer" + } + if strings.EqualFold(token.TokenType, "mac") { + return "MAC" + } + if strings.EqualFold(token.TokenType, "basic") { + return "Basic" + } + if token.TokenType != "" { + return token.TokenType + } + return "Bearer" +} diff --git a/connector/connector_oauth2_test.go b/connector/connector_oauth2_test.go new file mode 100644 index 00000000..f175e716 --- /dev/null +++ b/connector/connector_oauth2_test.go @@ -0,0 +1,63 @@ +package connector + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/coreos/go-oidc/oidc" + "github.com/kylelemons/godebug/pretty" +) + +type response struct { + statusCode int + body string +} + +type oauth2IdentityTest struct { + urlResps map[string]response + want oidc.Identity + wantErr error +} + +type fakeClient func(*http.Request) (*http.Response, error) + +// implement github.com/coreos/go-oidc/oauth2.Client +func (f fakeClient) Do(r *http.Request) (*http.Response, error) { + return f(r) +} + +func runOAuth2IdentityTests(t *testing.T, conn oauth2Connector, tests []oauth2IdentityTest) { + for i, tt := range tests { + f := func(req *http.Request) (*http.Response, error) { + resp, ok := tt.urlResps[req.URL.String()] + if !ok { + return nil, fmt.Errorf("unexpected request URL: %s", req.URL.String()) + } + return &http.Response{ + StatusCode: resp.statusCode, + Body: ioutil.NopCloser(strings.NewReader(resp.body)), + }, nil + } + got, err := conn.Identity(fakeClient(f)) + if tt.wantErr == nil { + if err != nil { + t.Errorf("case %d: failed to get identity=%v", i, err) + continue + } + if diff := pretty.Compare(tt.want, got); diff != "" { + t.Errorf("case %d: Compare(want, got) = %v", i, diff) + } + } else { + if err == nil { + t.Errorf("case %d: want error=%v, got=", i, tt.wantErr) + continue + } + if diff := pretty.Compare(tt.wantErr, err); diff != "" { + t.Errorf("case %d: Compare(wantErr, gotErr) = %v", i, diff) + } + } + } +}