diff --git a/connector/connector_bitbucket.go b/connector/connector_bitbucket.go new file mode 100644 index 00000000..94196c0b --- /dev/null +++ b/connector/connector_bitbucket.go @@ -0,0 +1,161 @@ +package connector + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "path" + + chttp "github.com/coreos/go-oidc/http" + "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" +) + +const ( + BitbucketConnectorType = "bitbucket" + bitbucketAuthURL = "https://bitbucket.org/site/oauth2/authorize" + bitbucketTokenURL = "https://bitbucket.org/site/oauth2/access_token" + bitbucketAPIUserURL = "https://bitbucket.org/api/2.0/user" + bitbucketAPIEmailURL = "https://api.bitbucket.org/2.0/user/emails" +) + +func init() { + RegisterConnectorConfigType(BitbucketConnectorType, func() ConnectorConfig { return &BitbucketConnectorConfig{} }) +} + +type BitbucketConnectorConfig struct { + ID string `json:"id"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` +} + +func (cfg *BitbucketConnectorConfig) ConnectorID() string { + return cfg.ID +} + +func (cfg *BitbucketConnectorConfig) ConnectorType() string { + return BitbucketConnectorType +} + +func (cfg *BitbucketConnectorConfig) Connector(ns url.URL, lf oidc.LoginFunc, tpls *template.Template) (Connector, error) { + ns.Path = path.Join(ns.Path, httpPathCallback) + oauth2Conn, err := newBitbucketConnector(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 bitbucketOAuth2Connector struct { + clientID string + clientSecret string + client *oauth2.Client +} + +func newBitbucketConnector(clientID, clientSecret, cbURL string) (oauth2Connector, error) { + config := oauth2.Config{ + Credentials: oauth2.ClientCredentials{ID: clientID, Secret: clientSecret}, + AuthURL: bitbucketAuthURL, + TokenURL: bitbucketTokenURL, + AuthMethod: oauth2.AuthMethodClientSecretPost, + RedirectURL: cbURL, + } + + cli, err := oauth2.NewClient(http.DefaultClient, config) + if err != nil { + return nil, err + } + + return &bitbucketOAuth2Connector{ + clientID: clientID, + clientSecret: clientSecret, + client: cli, + }, nil +} + +func (c *bitbucketOAuth2Connector) Client() *oauth2.Client { + return c.client +} + +func (c *bitbucketOAuth2Connector) Identity(cli chttp.Client) (oidc.Identity, error) { + var user struct { + UUID string `json:"uuid"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + if err := getAndDecode(cli, bitbucketAPIUserURL, &user); err != nil { + return oidc.Identity{}, fmt.Errorf("getting user info: %v", err) + } + + name := user.DisplayName + if name == "" { + name = user.Username + } + + var emails struct { + Values []struct { + Email string `json:"email"` + Confirmed bool `json:"is_confirmed"` + Primary bool `json:"is_primary"` + } `json:"values"` + } + if err := getAndDecode(cli, bitbucketAPIEmailURL, &emails); err != nil { + return oidc.Identity{}, fmt.Errorf("getting user email: %v", err) + } + email := "" + for _, val := range emails.Values { + if !val.Confirmed { + continue + } + if email == "" || val.Primary { + email = val.Email + } + if val.Primary { + break + } + } + + return oidc.Identity{ + ID: user.UUID, + Name: name, + Email: email, + }, nil +} + +func getAndDecode(cli chttp.Client, url string, v interface{}) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + resp, err := cli.Do(req) + if err != nil { + return fmt.Errorf("get: %v", err) + } + defer resp.Body.Close() + switch { + case resp.StatusCode >= 400 && resp.StatusCode < 500: + return oauth2.NewError(oauth2.ErrorAccessDenied) + case resp.StatusCode == http.StatusOK: + default: + return fmt.Errorf("unexpected status from providor %s", resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + return fmt.Errorf("decode body: %v", err) + } + return nil +} + +func (c *bitbucketOAuth2Connector) Healthy() error { + return nil +} + +func (c *bitbucketOAuth2Connector) TrustedEmailProvider() bool { + return false +} diff --git a/connector/connector_bitbucket_test.go b/connector/connector_bitbucket_test.go new file mode 100644 index 00000000..39fabfa0 --- /dev/null +++ b/connector/connector_bitbucket_test.go @@ -0,0 +1,59 @@ +package connector + +import ( + "net/http" + "testing" + + "github.com/coreos/go-oidc/oidc" +) + +var bitbucketExampleUser1 = `{ + "display_name": "tutorials account", + "username": "tutorials", + "uuid": "{c788b2da-b7a2-404c-9e26-d3f077557007}" +}` + +var bitbucketExampleUser2 = `{ + "username": "tutorials", + "uuid": "{c788b2da-b7a2-404c-9e26-d3f077557007}" +}` + +var bitbucketExampleEmail = `{ + "values": [ + {"email": "tutorials1@bitbucket.org","is_confirmed": false,"is_primary": false}, + {"email": "tutorials2@bitbucket.org","is_confirmed": true,"is_primary": false}, + {"email": "tutorials3@bitbucket.org","is_confirmed": true,"is_primary": true} + ] +}` + +func TestBitBucketIdentity(t *testing.T) { + tests := []oauth2IdentityTest{ + { + urlResps: map[string]response{ + bitbucketAPIUserURL: {http.StatusOK, bitbucketExampleUser1}, + bitbucketAPIEmailURL: {http.StatusOK, bitbucketExampleEmail}, + }, + want: oidc.Identity{ + Name: "tutorials account", + ID: "{c788b2da-b7a2-404c-9e26-d3f077557007}", + Email: "tutorials3@bitbucket.org", + }, + }, + { + urlResps: map[string]response{ + bitbucketAPIUserURL: {http.StatusOK, bitbucketExampleUser2}, + bitbucketAPIEmailURL: {http.StatusOK, bitbucketExampleEmail}, + }, + want: oidc.Identity{ + Name: "tutorials", + ID: "{c788b2da-b7a2-404c-9e26-d3f077557007}", + Email: "tutorials3@bitbucket.org", + }, + }, + } + conn, err := newBitbucketConnector("fakeclientid", "fakeclientsecret", "http://example.com/auth/bitbucket/callback") + if err != nil { + t.Fatal(err) + } + runOAuth2IdentityTests(t, conn, tests) +}