diff --git a/README.md b/README.md index d347d1532..a35746e4e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ $ go get github.com/markbates/goth * OpenID Connect (auto discovery) * Paypal * SalesForce +* Shopify * Slack * Soundcloud * Spotify diff --git a/examples/main.go b/examples/main.go index 6162d83e7..7743ad0bf 100644 --- a/examples/main.go +++ b/examples/main.go @@ -46,6 +46,7 @@ import ( "github.com/markbates/goth/providers/openidConnect" "github.com/markbates/goth/providers/paypal" "github.com/markbates/goth/providers/salesforce" + "github.com/markbates/goth/providers/shopify" "github.com/markbates/goth/providers/slack" "github.com/markbates/goth/providers/soundcloud" "github.com/markbates/goth/providers/spotify" @@ -122,6 +123,7 @@ func main() { yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "http://localhost:3000/auth/yandex/callback"), nextcloud.NewCustomisedDNS(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "http://localhost:3000/auth/nextcloud/callback", os.Getenv("NEXTCLOUD_URL")), gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "http://localhost:3000/auth/gitea/callback"), + shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "http://localhost:3000/auth/shopify/callback", shopify.ScopeReadCustomers, shopify.ScopeReadOrders), ) // OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html) @@ -149,6 +151,7 @@ func main() { m["gitlab"] = "Gitlab" m["google"] = "Google" m["gplus"] = "Google Plus" + m["shopify"] = "Shopify" m["soundcloud"] = "SoundCloud" m["spotify"] = "Spotify" m["steam"] = "Steam" diff --git a/providers/shopify/scopes.go b/providers/shopify/scopes.go new file mode 100644 index 000000000..52c8e52db --- /dev/null +++ b/providers/shopify/scopes.go @@ -0,0 +1,49 @@ +package shopify + +// Define scopes supported by Shopify. +// See: https://help.shopify.com/en/api/getting-started/authentication/oauth/scopes#authenticated-access-scopes +const ( + ScopeReadContent = "read_content" + ScopeWriteContent = "write_content" + ScopeReadThemes = "read_themes" + ScopeWriteThemes = "write_themes" + ScopeReadProducts = "read_products" + ScopeWriteProducts = "write_products" + ScopeReadProductListings = "read_product_listings" + ScopeReadCustomers = "read_customers" + ScopeWriteCustomers = "write_customers" + ScopeReadOrders = "read_orders" + ScopeWriteOrders = "write_orders" + ScopeReadDrafOrders = "read_draft_orders" + ScopeWriteDrafOrders = "write_draft_orders" + ScopeReadInventory = "read_inventory" + ScopeWriteInventory = "write_inventory" + ScopeReadLocations = "read_locations" + ScopeReadScriptTags = "read_script_tags" + ScopeWriteScriptTags = "write_script_tags" + ScopeReadFulfillments = "read_fulfillments" + ScopeWriteFulfillments = "write_fulfillments" + ScopeReadShipping = "read_shipping" + ScopeWriteShipping = "write_shipping" + ScopeReadAnalytics = "read_analytics" + ScopeReadUsers = "read_users" + ScopeWriteUsers = "write_users" + ScopeReadCheckouts = "read_checkouts" + ScopeWriteCheckouts = "write_checkouts" + ScopeReadReports = "read_reports" + ScopeWriteReports = "write_reports" + ScopeReadPriceRules = "read_price_rules" + ScopeWritePriceRules = "write_price_rules" + ScopeMarketingEvents = "read_marketing_events" + ScopeWriteMarketingEvents = "write_marketing_events" + ScopeReadResourceFeedbacks = "read_resource_feedbacks" + ScopeWriteResourceFeedbacks = "write_resource_feedbacks" + ScopeReadShopifyPaymentsPayouts = "read_shopify_payments_payouts" + ScopeReadShopifyPaymentsDisputes = "read_shopify_payments_disputes" + + // Special: + // Grants access to all orders rather than the default window of 60 days worth of orders. + // This OAuth scope is used in conjunction with read_orders, or write_orders. You need to request + // this scope from your Partner Dashboard before adding it to your app. + ScopeReadAllOrders = "read_all_orders" +) diff --git a/providers/shopify/session.go b/providers/shopify/session.go new file mode 100755 index 000000000..8a4b0c6ee --- /dev/null +++ b/providers/shopify/session.go @@ -0,0 +1,102 @@ +package shopify + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/markbates/goth" +) + +const ( + shopifyHostnameRegex = `^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$` +) + +// Session stores data during the auth process with Shopify. +type Session struct { + AuthURL string + AccessToken string + Hostname string + HMAC string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Shopify provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Shopify and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + // Validate the incoming HMAC is valid. + // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#verification + digest := fmt.Sprintf( + "code=%s&shop=%s&state=%s×tamp=%s", + params.Get("code"), + params.Get("shop"), + params.Get("state"), + params.Get("timestamp"), + ) + h := hmac.New(sha256.New, []byte(os.Getenv("SHOPIFY_SECRET"))) + h.Write([]byte(digest)) + sha := hex.EncodeToString(h.Sum(nil)) + + // Ensure our HMAC hash's match. + if sha != params.Get("hmac") { + return "", errors.New("Invalid HMAC received") + } + + // Validate the hostname matches what we're expecting. + // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#step-3-confirm-installation + re := regexp.MustCompile(shopifyHostnameRegex) + if !re.MatchString(params.Get("shop")) { + return "", errors.New("Invalid hostname received") + } + + // Make the exchange for an access token. + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + // Ensure it's valid. + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.Hostname = params.Get("hostname") + s.HMAC = params.Get("hmac") + + 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/shopify/session_test.go b/providers/shopify/session_test.go new file mode 100755 index 000000000..85ea9adc0 --- /dev/null +++ b/providers/shopify/session_test.go @@ -0,0 +1,48 @@ +package shopify_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/shopify" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.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 := &shopify.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","Hostname":"","HMAC":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/shopify/shopify.go b/providers/shopify/shopify.go new file mode 100755 index 000000000..e3d2ae8fb --- /dev/null +++ b/providers/shopify/shopify.go @@ -0,0 +1,193 @@ +// Package shopify implements the OAuth2 protocol for authenticating users through Shopify. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package shopify + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "fmt" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + providerName = "shopify" + + // URL protocol and subdomain will be populated by newConfig(). + authURL = "myshopify.com/admin/oauth/authorize" + tokenURL = "myshopify.com/admin/oauth/access_token" + endpointProfile = "myshopify.com/admin/api/2019-04/shop.json" +) + +// Provider is the implementation of `goth.Provider` for accessing Shopify. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + shopName string + scopes []string +} + +// New creates a new Shopify provider and sets up important connection details. +// You should always call `shopify.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: providerName, + scopes: scopes, + } + p.config = newConfig(p, scopes) + return p +} + +// Client is HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// 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 +} + +// SetShopName is to update the shopify shop name, needed when interfacing with different shops. +func (p *Provider) SetShopName(name string) { + p.shopName = name + + // Reparse config with the new shop name. + p.config = newConfig(p, p.scopes) +} + +// Debug is a no-op for the Shopify package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Shopify for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by Shopify") +} + +// FetchUser will go to Shopify and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + shop := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + } + + if shop.AccessToken == "" { + // Data is not yet retrieved since accessToken is still empty. + return shop, fmt.Errorf("%s cannot get shop information without accessToken", p.providerName) + } + + // Build the request. + req, err := http.NewRequest("GET", fmt.Sprintf("https://%s.%s", p.shopName, endpointProfile), nil) + if err != nil { + return shop, err + } + req.Header.Set("X-Shopify-Access-Token", s.AccessToken) + + // Execute the request. + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return shop, err + } + defer resp.Body.Close() + + // Check our response status. + if resp.StatusCode != http.StatusOK { + return shop, fmt.Errorf("%s responded with a %d trying to fetch shop information", p.providerName, resp.StatusCode) + } + + // Parse response. + return shop, shopFromReader(resp.Body, &shop) +} + +func shopFromReader(r io.Reader, shop *goth.User) error { + rsp := struct { + Shop struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + City string `json:"city"` + Country string `json:"country"` + ShopOwner string `json:"shop_owner"` + MyShopifyDomain string `json:"myshopify_domain"` + PlanDisplayName string `json:"plan_display_name"` + } `json:"shop"` + }{} + + err := json.NewDecoder(r).Decode(&rsp) + if err != nil { + return err + } + + shop.UserID = strconv.Itoa(int(rsp.Shop.ID)) + shop.Name = rsp.Shop.Name + shop.Email = rsp.Shop.Email + shop.Description = fmt.Sprintf("%s (%s)", rsp.Shop.MyShopifyDomain, rsp.Shop.PlanDisplayName) + shop.Location = fmt.Sprintf("%s, %s", rsp.Shop.City, rsp.Shop.Country) + shop.AvatarURL = "Not provided by the Shopify API" + shop.NickName = "Not provided by the Shopify API" + + return nil +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://%s.%s", p.shopName, authURL), + TokenURL: fmt.Sprintf("https://%s.%s", p.shopName, tokenURL), + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for i, scope := range scopes { + // Shopify require comma separated scopes. + s := fmt.Sprintf("%s,", scope) + if i == len(scopes)+1 { + s = scope + } + c.Scopes = append(c.Scopes, s) + } + } else { + // Default to a read customers scope. + c.Scopes = append(c.Scopes, ScopeReadCustomers) + } + + return c +} diff --git a/providers/shopify/shopify_test.go b/providers/shopify/shopify_test.go new file mode 100755 index 000000000..393a887c3 --- /dev/null +++ b/providers/shopify/shopify_test.go @@ -0,0 +1,55 @@ +package shopify_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/shopify" + "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("SHOPIFY_KEY")) + a.Equal(p.Secret, os.Getenv("SHOPIFY_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.(*shopify.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://test-shop.myshopify.com/admin/oauth/authorize","AccessToken":"1234567890"}"`) + a.NoError(err) + + s := session.(*shopify.Session) + a.Equal(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *shopify.Provider { + p := shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "/foo") + p.SetShopName("test-shop") + return p +}