From 172172b2171c32ed3197aa210b1be33657eb574a Mon Sep 17 00:00:00 2001 From: Justin Sweeney Date: Tue, 20 Feb 2024 18:01:00 -0500 Subject: [PATCH] Adding a Hubspot provider for Goth (#531) --- providers/hubspot/hubspot.go | 173 ++++++++++++++++++++++++++++++ providers/hubspot/hubspot_test.go | 53 +++++++++ providers/hubspot/session.go | 60 +++++++++++ providers/hubspot/session_test.go | 48 +++++++++ 4 files changed, 334 insertions(+) create mode 100644 providers/hubspot/hubspot.go create mode 100644 providers/hubspot/hubspot_test.go create mode 100644 providers/hubspot/session.go create mode 100644 providers/hubspot/session_test.go diff --git a/providers/hubspot/hubspot.go b/providers/hubspot/hubspot.go new file mode 100644 index 000000000..10b292ddc --- /dev/null +++ b/providers/hubspot/hubspot.go @@ -0,0 +1,173 @@ +package hubspot + +import ( + "encoding/json" + "fmt" + "github.com/markbates/goth" + "golang.org/x/oauth2" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// These vars define the Authentication and Token URLS for Hubspot. +var ( + AuthURL = "https://app.hubspot.com/oauth/authorize" + TokenURL = "https://api.hubapi.com/oauth/v1/token" +) + +const ( + userEndpoint = "https://api.hubapi.com/oauth/v1/access-tokens/" +) + +type hubspotUser struct { + Token string `json:"token"` + User string `json:"user"` + HubDomain string `json:"hub_domain"` + Scopes []string `json:"scopes"` + ScopeToScopeGroupPKs []int `json:"scope_to_scope_group_pks"` + TrialScopes []string `json:"trial_scopes"` + TrialScopeToScopeGroupPKs []int `json:"trial_scope_to_scope_group_pks"` + HubID int `json:"hub_id"` + AppID int `json:"app_id"` + ExpiresIn int `json:"expires_in"` + UserID int `json:"user_id"` + TokenType string `json:"token_type"` +} + +// Provider is the implementation of `goth.Provider` for accessing Hubspot. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Hubspot provider and sets up important connection details. +// You should always call `hubspot.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, + providerName: "hubspot", + } + p.config = newConfig(p, scopes) + return p +} + +// 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 hubspot package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Hubspot for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Hubspot and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(userEndpoint + url.QueryEscape(user.AccessToken)) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + var u hubspotUser + if err := json.Unmarshal(responseBytes, &u); err != nil { + return user, err + } + + // Extract the user data we got from Google into our goth.User. + user.Email = u.User + user.UserID = strconv.Itoa(u.UserID) + accessTokenExpiration := time.Now() + if u.ExpiresIn > 0 { + accessTokenExpiration = accessTokenExpiration.Add(time.Duration(u.ExpiresIn) * time.Second) + } else { + accessTokenExpiration = accessTokenExpiration.Add(30 * time.Minute) + } + user.ExpiresAt = accessTokenExpiration + // Google provides other useful fields such as 'hd'; get them from RawData + if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { + return user, err + } + + return user, nil +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: AuthURL, + TokenURL: TokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/hubspot/hubspot_test.go b/providers/hubspot/hubspot_test.go new file mode 100644 index 000000000..c8ecb6ae5 --- /dev/null +++ b/providers/hubspot/hubspot_test.go @@ -0,0 +1,53 @@ +package hubspot_test + +import ( + "github.com/markbates/goth/providers/hubspot" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("HUBSPOT_KEY")) + a.Equal(p.Secret, os.Getenv("HUBSPOT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*hubspot.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://app.hubspot.com/oauth/authoriz","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*hubspot.Session) + a.Equal(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *hubspot.Provider { + return hubspot.New(os.Getenv("HUBSPOT_KEY"), os.Getenv("HUBSPOT_SECRET"), "/foo") +} diff --git a/providers/hubspot/session.go b/providers/hubspot/session.go new file mode 100644 index 000000000..8cb3361f4 --- /dev/null +++ b/providers/hubspot/session.go @@ -0,0 +1,60 @@ +package hubspot + +import ( + "encoding/json" + "errors" + "github.com/markbates/goth" + "strings" +) + +// Session stores data during the auth process with Hubspot. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Hubspot provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Hubspot 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 + s.RefreshToken = token.RefreshToken + 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 wil 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/hubspot/session_test.go b/providers/hubspot/session_test.go new file mode 100644 index 000000000..bdc6f08d0 --- /dev/null +++ b/providers/hubspot/session_test.go @@ -0,0 +1,48 @@ +package hubspot_test + +import ( + "github.com/markbates/goth/providers/hubspot" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + a.Equal(s.String(), s.Marshal()) +}