Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding a Hubspot provider for Goth #531

Merged
merged 2 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
173 changes: 173 additions & 0 deletions providers/hubspot/hubspot.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions providers/hubspot/hubspot_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
60 changes: 60 additions & 0 deletions providers/hubspot/session.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions providers/hubspot/session_test.go
Original file line number Diff line number Diff line change
@@ -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())
}