diff --git a/providers/bitly/bitly.go b/providers/bitly/bitly.go new file mode 100644 index 000000000..f302d5fe4 --- /dev/null +++ b/providers/bitly/bitly.go @@ -0,0 +1,171 @@ +// Package bitly implements the OAuth2 protocol for authenticating users through Bitly. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package bitly + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authEndpoint string = "https://bitly.com/oauth/authorize" + tokenEndpoint string = "https://api-ssl.bitly.com/oauth/access_token" + profileEndpoint string = "https://api-ssl.bitly.com/v4/user" +) + +// New creates a new Bitly provider and sets up important connection details. +// You should always call `bitly.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + } + p.newConfig(scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Bitly. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Ensure `bitly.Provider` implements `goth.Provider`. +var _ goth.Provider = &Provider{} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type). +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the bitly package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Bitly for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Bitly and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + u := goth.User{ + Provider: p.Name(), + AccessToken: s.AccessToken, + } + + if u.AccessToken == "" { + return u, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", profileEndpoint, nil) + if err != nil { + return u, err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", u.AccessToken)) + + resp, err := p.Client().Do(req) + if err != nil { + return u, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + defer resp.Body.Close() + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + return u, err + } + + if err := json.NewDecoder(bytes.NewReader(buf)).Decode(&u.RawData); err != nil { + return u, err + } + + return u, userFromReader(bytes.NewReader(buf), &u) +} + +// RefreshToken refresh token is not provided by bitly. +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by bitly") +} + +// RefreshTokenAvailable refresh token is not provided by bitly. +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +func (p *Provider) newConfig(scopes []string) { + conf := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authEndpoint, + TokenURL: tokenEndpoint, + }, + Scopes: make([]string, 0), + } + + conf.Scopes = append(conf.Scopes, scopes...) + + p.config = conf +} + +func userFromReader(reader io.Reader, user *goth.User) (err error) { + u := struct { + Login string `json:"login"` + Name string `json:"name"` + Emails []struct { + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsVerified bool `json:"is_verified"` + } `json:"emails"` + }{} + if err := json.NewDecoder(reader).Decode(&u); err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Login + user.Email, err = getEmail(u.Emails) + return err +} + +func getEmail(emails []struct { + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsVerified bool `json:"is_verified"` +}) (string, error) { + for _, email := range emails { + if email.IsPrimary && email.IsVerified { + return email.Email, nil + } + } + + return "", fmt.Errorf("The user does not have a verified, primary email address on Bitly") +} diff --git a/providers/bitly/bitly_test.go b/providers/bitly/bitly_test.go new file mode 100644 index 000000000..d48078a80 --- /dev/null +++ b/providers/bitly/bitly_test.go @@ -0,0 +1,52 @@ +package bitly_test + +import ( + "fmt" + "net/url" + "testing" + + "github.com/markbates/goth/providers/bitly" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + a.Equal(p.ClientKey, "bitly_client_id") + a.Equal(p.Secret, "bitly_client_secret") + a.Equal(p.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + s, err := p.BeginAuth("state") + s1 := s.(*bitly.Session) + + a.NoError(err) + a.Contains(s1.AuthURL, "https://bitly.com/oauth/authorize") + a.Contains(s1.AuthURL, fmt.Sprintf("client_id=%s", p.ClientKey)) + a.Contains(s1.AuthURL, "state=state") + a.Contains(s1.AuthURL, fmt.Sprintf("redirect_uri=%s", url.QueryEscape(p.CallbackURL))) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + s, err := p.UnmarshalSession(`{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) + s1 := s.(*bitly.Session) + + a.NoError(err) + a.Equal(s1.AuthURL, "https://bitly.com/oauth/authorize") + a.Equal(s1.AccessToken, "access_token") +} + +func bitlyProvider() *bitly.Provider { + return bitly.New("bitly_client_id", "bitly_client_secret", "/foo") +} diff --git a/providers/bitly/session.go b/providers/bitly/session.go new file mode 100644 index 000000000..dbe876af7 --- /dev/null +++ b/providers/bitly/session.go @@ -0,0 +1,59 @@ +package bitly + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Bitly. +type Session struct { + AuthURL string + AccessToken string +} + +// Ensure `bitly.Session` implements `goth.Session`. +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Bitly provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Bitly and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string. +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/bitly/session_test.go b/providers/bitly/session_test.go new file mode 100644 index 000000000..c734f0ccf --- /dev/null +++ b/providers/bitly/session_test.go @@ -0,0 +1,33 @@ +package bitly_test + +import ( + "testing" + + "github.com/markbates/goth/providers/bitly" + "github.com/stretchr/testify/assert" +) + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + + s := &bitly.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/bar" + url, _ := s.GetAuthURL() + a.Equal(url, "/bar") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + s := &bitly.Session{ + AuthURL: "https://bitly.com/oauth/authorize", + AccessToken: "access_token", + } + a.Equal(s.Marshal(), `{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) +}