Skip to content

Commit 2f19eba

Browse files
committed
add quickbooks provider
add all supported scopes add examples fix
1 parent 4b34e17 commit 2f19eba

6 files changed

Lines changed: 410 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ $ go get github.com/markbates/goth
5757
* Oura
5858
* Patreon
5959
* Paypal
60+
* Quickbooks
6061
* Reddit
6162
* SalesForce
6263
* Shopify

examples/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import (
4848
"github.com/markbates/goth/providers/openidConnect"
4949
"github.com/markbates/goth/providers/patreon"
5050
"github.com/markbates/goth/providers/paypal"
51+
"github.com/markbates/goth/providers/quickbooks"
5152
"github.com/markbates/goth/providers/salesforce"
5253
"github.com/markbates/goth/providers/seatalk"
5354
"github.com/markbates/goth/providers/shopify"
@@ -149,6 +150,7 @@ func main() {
149150
wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "http://localhost:3000/auth/wecom/callback"),
150151
zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "http://localhost:3000/auth/zoom/callback", "read:user"),
151152
patreon.New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "http://localhost:3000/auth/patreon/callback"),
153+
quickbooks.New(os.Getenv("QUICKBOOKS_KEY"), os.Getenv("QUICKBOOKS_SECRET"), "http://localhost:3000/auth/quickbooks/callback", nil, quickbooks.ScopeAccounting, quickbooks.ScopePayments),
152154
)
153155

154156
// OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html)
@@ -197,6 +199,7 @@ func main() {
197199
"openid-connect": "OpenID Connect",
198200
"patreon": "Patreon",
199201
"paypal": "Paypal",
202+
"quickbooks": "Quickbooks",
200203
"salesforce": "Salesforce",
201204
"seatalk": "SeaTalk",
202205
"shopify": "Shopify",

providers/quickbooks/quickbooks.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package quickbooks
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/markbates/goth"
10+
"golang.org/x/oauth2"
11+
)
12+
13+
const (
14+
authEndpoint = "https://appcenter.intuit.com/connect/oauth2"
15+
tokenEndpoint = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
16+
userInfoURL = "https://accounts.platform.intuit.com/v1/openid_connect/userinfo"
17+
18+
ScopeAccounting = "com.intuit.quickbooks.accounting"
19+
ScopePayments = "com.intuit.quickbooks.payments"
20+
ScopeOpenId = "openid"
21+
ScopeEmail = "email"
22+
ScopeProfile = "profile"
23+
ScopePhone = "phone"
24+
ScopeAddress = "address"
25+
)
26+
27+
type Provider struct {
28+
providerName string
29+
clientId string
30+
secret string
31+
redirectURL string
32+
config *oauth2.Config
33+
httpClient *http.Client
34+
userInfoURL string
35+
}
36+
37+
func New(clientId, secret, redirectURL string, httpClient *http.Client, scopes ...string) *Provider {
38+
p := &Provider{
39+
clientId: clientId,
40+
secret: secret,
41+
redirectURL: redirectURL,
42+
providerName: "quickbooks",
43+
userInfoURL: userInfoURL,
44+
}
45+
p.configure(scopes)
46+
p.httpClient = httpClient
47+
return p
48+
}
49+
50+
func (p Provider) Name() string {
51+
return p.providerName
52+
}
53+
54+
func (p *Provider) SetName(name string) {
55+
p.providerName = name
56+
}
57+
58+
func (p Provider) ClientId() string {
59+
return p.clientId
60+
}
61+
62+
func (p Provider) Secret() string {
63+
return p.secret
64+
}
65+
66+
func (p Provider) RedirectURL() string {
67+
return p.redirectURL
68+
}
69+
70+
func (p Provider) BeginAuth(state string) (goth.Session, error) {
71+
authURL := p.config.AuthCodeURL(state)
72+
return &Session{
73+
AuthURL: authURL,
74+
}, nil
75+
}
76+
77+
func (Provider) UnmarshalSession(data string) (goth.Session, error) {
78+
s := &Session{}
79+
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
80+
return s, err
81+
}
82+
83+
func (p Provider) FetchUser(session goth.Session) (goth.User, error) {
84+
s := session.(*Session)
85+
if s.AccessToken == "" {
86+
return goth.User{}, fmt.Errorf("no access token obtained for session with provider %s", p.Name())
87+
}
88+
89+
req, err := http.NewRequest("GET", p.userInfoURL, nil)
90+
if err != nil {
91+
return goth.User{}, err
92+
}
93+
req.Header.Set("Authorization", "Bearer "+s.AccessToken)
94+
95+
resp, err := p.Client().Do(req)
96+
if err != nil {
97+
return goth.User{}, err
98+
}
99+
defer resp.Body.Close()
100+
101+
if resp.StatusCode != http.StatusOK {
102+
return goth.User{}, fmt.Errorf("failed to get user info: %d", resp.StatusCode)
103+
}
104+
105+
var userInfo struct {
106+
Sub string `json:"sub"`
107+
Email string `json:"email"`
108+
EmailVerified bool `json:"email_verified"`
109+
Name string `json:"name"`
110+
GivenName string `json:"given_name"`
111+
FamilyName string `json:"family_name"`
112+
}
113+
114+
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
115+
return goth.User{}, err
116+
}
117+
118+
return goth.User{
119+
Provider: p.Name(),
120+
UserID: userInfo.Sub,
121+
Email: userInfo.Email,
122+
Name: userInfo.Name,
123+
FirstName: userInfo.GivenName,
124+
LastName: userInfo.FamilyName,
125+
AccessToken: s.AccessToken,
126+
RefreshToken: s.RefreshToken,
127+
ExpiresAt: s.ExpiresAt,
128+
}, nil
129+
}
130+
131+
func (Provider) Debug(bool) {}
132+
133+
func (p Provider) Client() *http.Client {
134+
return goth.HTTPClientWithFallBack(p.httpClient)
135+
}
136+
137+
func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
138+
token := &oauth2.Token{RefreshToken: refreshToken}
139+
ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token)
140+
newToken, err := ts.Token()
141+
if err != nil {
142+
return nil, err
143+
}
144+
return newToken, err
145+
}
146+
147+
func (Provider) RefreshTokenAvailable() bool {
148+
return true
149+
}
150+
151+
func (p *Provider) configure(scopes []string) {
152+
c := &oauth2.Config{
153+
ClientID: p.clientId,
154+
ClientSecret: p.secret,
155+
RedirectURL: p.redirectURL,
156+
Endpoint: oauth2.Endpoint{
157+
AuthURL: authEndpoint,
158+
TokenURL: tokenEndpoint,
159+
},
160+
Scopes: make([]string, 0, len(scopes)),
161+
}
162+
163+
c.Scopes = append(c.Scopes, scopes...)
164+
p.config = c
165+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package quickbooks
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/markbates/goth"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func Test_New(t *testing.T) {
15+
t.Parallel()
16+
a := assert.New(t)
17+
18+
provider := New("client-id", "secret", "http://example.com/callback", nil, ScopeAccounting)
19+
a.Equal(provider.ClientId(), "client-id")
20+
a.Equal(provider.Secret(), "secret")
21+
a.Equal(provider.RedirectURL(), "http://example.com/callback")
22+
a.Equal(provider.Name(), "quickbooks")
23+
}
24+
25+
func Test_Implements_Provider(t *testing.T) {
26+
t.Parallel()
27+
a := assert.New(t)
28+
a.Implements((*goth.Provider)(nil), New("", "", "", nil))
29+
}
30+
31+
func Test_BeginAuth(t *testing.T) {
32+
t.Parallel()
33+
a := assert.New(t)
34+
35+
provider := New("client-id", "secret", "http://example.com/callback", nil, ScopeAccounting)
36+
session, err := provider.BeginAuth("test_state")
37+
s := session.(*Session)
38+
a.NoError(err)
39+
a.Contains(s.AuthURL, "appcenter.intuit.com/connect/oauth2")
40+
a.Contains(s.AuthURL, "client_id=client-id")
41+
a.Contains(s.AuthURL, "state=test_state")
42+
a.Contains(s.AuthURL, "scope=com.intuit.quickbooks.accounting")
43+
}
44+
45+
func Test_FetchUser(t *testing.T) {
46+
t.Parallel()
47+
a := assert.New(t)
48+
49+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+
a.Equal(r.Header.Get("Authorization"), "Bearer access_token")
51+
w.Header().Set("Content-Type", "application/json")
52+
json.NewEncoder(w).Encode(map[string]interface{}{
53+
"sub": "user123",
54+
"email": "user@example.com",
55+
"email_verified": true,
56+
"name": "John Doe",
57+
"given_name": "John",
58+
"family_name": "Doe",
59+
})
60+
}))
61+
defer ts.Close()
62+
63+
provider := New("client-id", "secret", "http://example.com/callback", nil, ScopeAccounting)
64+
provider.userInfoURL = ts.URL
65+
session := &Session{
66+
AccessToken: "access_token",
67+
ExpiresAt: time.Now().Add(time.Hour),
68+
}
69+
70+
user, err := provider.FetchUser(session)
71+
a.NoError(err)
72+
a.Equal(user.UserID, "user123")
73+
a.Equal(user.Email, "user@example.com")
74+
a.Equal(user.Name, "John Doe")
75+
a.Equal(user.FirstName, "John")
76+
a.Equal(user.LastName, "Doe")
77+
a.Equal(user.AccessToken, "access_token")
78+
}
79+
80+
func Test_RefreshToken(t *testing.T) {
81+
t.Parallel()
82+
a := assert.New(t)
83+
84+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85+
a.Equal(r.Method, "POST")
86+
a.Equal(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")
87+
88+
w.Header().Set("Content-Type", "application/json")
89+
json.NewEncoder(w).Encode(map[string]interface{}{
90+
"access_token": "new_access_token",
91+
"token_type": "bearer",
92+
"expires_in": 3600,
93+
"refresh_token": "new_refresh_token",
94+
})
95+
}))
96+
defer ts.Close()
97+
98+
provider := New("client-id", "secret", "http://example.com/callback", nil, ScopeAccounting)
99+
provider.config.Endpoint.TokenURL = ts.URL
100+
101+
token, err := provider.RefreshToken("refresh_token")
102+
a.NoError(err)
103+
a.NotNil(token)
104+
a.Equal(token.AccessToken, "new_access_token")
105+
a.Equal(token.RefreshToken, "new_refresh_token")
106+
a.True(token.Expiry.After(time.Now()))
107+
}
108+
109+
func Test_RefreshTokenAvailable(t *testing.T) {
110+
t.Parallel()
111+
a := assert.New(t)
112+
113+
provider := New("client-id", "secret", "http://example.com/callback", nil, ScopeAccounting)
114+
a.True(provider.RefreshTokenAvailable())
115+
}

providers/quickbooks/session.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package quickbooks
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"time"
7+
8+
"github.com/markbates/goth"
9+
)
10+
11+
type Session struct {
12+
AuthURL string
13+
AccessToken string
14+
RefreshToken string
15+
ExpiresAt time.Time
16+
}
17+
18+
var _ goth.Session = &Session{}
19+
20+
func (s Session) GetAuthURL() (string, error) {
21+
if s.AuthURL == "" {
22+
return "", errors.New(goth.NoAuthUrlErrorMessage)
23+
}
24+
return s.AuthURL, nil
25+
}
26+
27+
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
28+
p := provider.(*Provider)
29+
token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"))
30+
if err != nil {
31+
return "", err
32+
}
33+
34+
if !token.Valid() {
35+
return "", errors.New("invalid token received from provider")
36+
}
37+
38+
s.AccessToken = token.AccessToken
39+
s.RefreshToken = token.RefreshToken
40+
s.ExpiresAt = token.Expiry
41+
return token.AccessToken, err
42+
}
43+
44+
func (s Session) Marshal() string {
45+
b, _ := json.Marshal(s)
46+
return string(b)
47+
}
48+
49+
func (s Session) String() string {
50+
return s.Marshal()
51+
}

0 commit comments

Comments
 (0)