Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ $ go get github.com/markbates/goth
* Oura
* Patreon
* Paypal
* Quickbooks
* Reddit
* SalesForce
* Shopify
Expand Down
3 changes: 3 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import (
"github.com/markbates/goth/providers/openidConnect"
"github.com/markbates/goth/providers/patreon"
"github.com/markbates/goth/providers/paypal"
"github.com/markbates/goth/providers/quickbooks"
"github.com/markbates/goth/providers/salesforce"
"github.com/markbates/goth/providers/seatalk"
"github.com/markbates/goth/providers/shopify"
Expand Down Expand Up @@ -149,6 +150,7 @@ func main() {
wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "http://localhost:3000/auth/wecom/callback"),
zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "http://localhost:3000/auth/zoom/callback", "read:user"),
patreon.New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "http://localhost:3000/auth/patreon/callback"),
quickbooks.New(os.Getenv("QUICKBOOKS_KEY"), os.Getenv("QUICKBOOKS_SECRET"), "http://localhost:3000/auth/quickbooks/callback", false, quickbooks.ScopeAccounting, quickbooks.ScopePayments),
)

// OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html)
Expand Down Expand Up @@ -197,6 +199,7 @@ func main() {
"openid-connect": "OpenID Connect",
"patreon": "Patreon",
"paypal": "Paypal",
"quickbooks": "Quickbooks",
"salesforce": "Salesforce",
"seatalk": "SeaTalk",
"shopify": "Shopify",
Expand Down
183 changes: 183 additions & 0 deletions providers/quickbooks/quickbooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package quickbooks

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/markbates/goth"
"golang.org/x/oauth2"
)

const (
authEndpoint = "https://appcenter.intuit.com/connect/oauth2"
tokenEndpoint = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
userInfoURL = "https://accounts.platform.intuit.com/v1/openid_connect/userinfo"
sandboxUserInfoURL = "https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo"

ScopeAccounting = "com.intuit.quickbooks.accounting"
ScopePayments = "com.intuit.quickbooks.payments"
ScopeOpenId = "openid"
ScopeEmail = "email"
ScopeProfile = "profile"
ScopePhone = "phone"
ScopeAddress = "address"
)

type Provider struct {
providerName string
clientId string
secret string
redirectURL string
config *oauth2.Config
httpClient *http.Client
userInfoURL string
}

func New(clientId, secret, redirectURL string, isSandbox bool, scopes ...string) *Provider {
p := &Provider{
clientId: clientId,
secret: secret,
redirectURL: redirectURL,
providerName: "quickbooks",
userInfoURL: userInfoURL,
}
if isSandbox {
p.userInfoURL = sandboxUserInfoURL
}
p.configure(scopes)
return p
}

func (p Provider) Name() string {
return p.providerName
}

func (p *Provider) SetName(name string) {
p.providerName = name
}

func (p Provider) ClientId() string {
return p.clientId
}

func (p Provider) Secret() string {
return p.secret
}

func (p Provider) RedirectURL() string {
return p.redirectURL
}

func (p Provider) BeginAuth(state string) (goth.Session, error) {
authURL := p.config.AuthCodeURL(state)
return &Session{
AuthURL: authURL,
}, nil
}

func (Provider) UnmarshalSession(data string) (goth.Session, error) {
s := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
return s, err
}

func (p Provider) FetchUser(session goth.Session) (goth.User, error) {
s := session.(*Session)
user := goth.User{
Provider: p.Name(),
}

if s.AccessToken == "" {
return goth.User{}, fmt.Errorf("no access token obtained for session with provider %s", p.Name())
}

req, err := http.NewRequest("GET", p.userInfoURL, nil)
if err != nil {
return goth.User{}, err
}
req.Header.Set("Authorization", "Bearer "+s.AccessToken)

resp, err := p.Client().Do(req)
if err != nil {
return goth.User{}, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return user, fmt.Errorf("failed to get user info: %d", resp.StatusCode)
}

bits, err := io.ReadAll(resp.Body)
if err != nil {
return user, err
}

u := struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
}{}

if err := json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil {
return user, err
}

user.UserID = u.Sub
user.Email = u.Email
user.Name = u.Name
user.FirstName = u.GivenName
user.LastName = u.FamilyName
user.AccessToken = s.AccessToken
user.RefreshToken = s.RefreshToken
user.ExpiresAt = s.ExpiresAt

err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
if err != nil {
return user, err
}

return user, err
}

func (Provider) Debug(bool) {}

func (p Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.httpClient)
}

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
}

func (Provider) RefreshTokenAvailable() bool {
return true
}

func (p *Provider) configure(scopes []string) {
c := &oauth2.Config{
ClientID: p.clientId,
ClientSecret: p.secret,
RedirectURL: p.redirectURL,
Endpoint: oauth2.Endpoint{
AuthURL: authEndpoint,
TokenURL: tokenEndpoint,
},
Scopes: make([]string, 0, len(scopes)),
}

c.Scopes = append(c.Scopes, scopes...)
p.config = c
}
115 changes: 115 additions & 0 deletions providers/quickbooks/quickbooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package quickbooks

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/markbates/goth"
"github.com/stretchr/testify/assert"
)

func Test_New(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := New("client-id", "secret", "http://example.com/callback", false, ScopeAccounting)
a.Equal(provider.ClientId(), "client-id")
a.Equal(provider.Secret(), "secret")
a.Equal(provider.RedirectURL(), "http://example.com/callback")
a.Equal(provider.Name(), "quickbooks")
}

func Test_Implements_Provider(t *testing.T) {
t.Parallel()
a := assert.New(t)
a.Implements((*goth.Provider)(nil), New("", "", "", false))
}

func Test_BeginAuth(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := New("client-id", "secret", "http://example.com/callback", false, ScopeAccounting)
session, err := provider.BeginAuth("test_state")
s := session.(*Session)
a.NoError(err)
a.Contains(s.AuthURL, "appcenter.intuit.com/connect/oauth2")
a.Contains(s.AuthURL, "client_id=client-id")
a.Contains(s.AuthURL, "state=test_state")
a.Contains(s.AuthURL, "scope=com.intuit.quickbooks.accounting")
}

func Test_FetchUser(t *testing.T) {
t.Parallel()
a := assert.New(t)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.Equal(r.Header.Get("Authorization"), "Bearer access_token")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": "user123",
"email": "[email protected]",
"email_verified": true,
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
})
}))
defer ts.Close()

provider := New("client-id", "secret", "http://example.com/callback", false, ScopeAccounting)
provider.userInfoURL = ts.URL
session := &Session{
AccessToken: "access_token",
ExpiresAt: time.Now().Add(time.Hour),
}

user, err := provider.FetchUser(session)
a.NoError(err)
a.Equal(user.UserID, "user123")
a.Equal(user.Email, "[email protected]")
a.Equal(user.Name, "John Doe")
a.Equal(user.FirstName, "John")
a.Equal(user.LastName, "Doe")
a.Equal(user.AccessToken, "access_token")
}

func Test_RefreshToken(t *testing.T) {
t.Parallel()
a := assert.New(t)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.Equal(r.Method, "POST")
a.Equal(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new_access_token",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "new_refresh_token",
})
}))
defer ts.Close()

provider := New("client-id", "secret", "http://example.com/callback", false, ScopeAccounting)
provider.config.Endpoint.TokenURL = ts.URL

token, err := provider.RefreshToken("refresh_token")
a.NoError(err)
a.NotNil(token)
a.Equal(token.AccessToken, "new_access_token")
a.Equal(token.RefreshToken, "new_refresh_token")
a.True(token.Expiry.After(time.Now()))
}

func Test_RefreshTokenAvailable(t *testing.T) {
t.Parallel()
a := assert.New(t)

provider := New("client-id", "secret", "http://example.com/callback", false, ScopeAccounting)
a.True(provider.RefreshTokenAvailable())
}
51 changes: 51 additions & 0 deletions providers/quickbooks/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package quickbooks

import (
"encoding/json"
"errors"
"time"

"github.com/markbates/goth"
)

type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
}

var _ goth.Session = &Session{}

func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New(goth.NoAuthUrlErrorMessage)
}
return s.AuthURL, nil
}

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
s.ExpiresAt = token.Expiry
return token.AccessToken, err
}

func (s Session) Marshal() string {
b, _ := json.Marshal(s)
return string(b)
}

func (s Session) String() string {
return s.Marshal()
}
Loading
Loading