diff --git a/clientcredentials/clientcredentials.go b/clientcredentials/clientcredentials.go new file mode 100644 index 0000000..076a4bf --- /dev/null +++ b/clientcredentials/clientcredentials.go @@ -0,0 +1,121 @@ +// Package clientcredentials implements the eBay client credentials grant flow +// to generate "Application access" tokens. +// eBay doc: https://developer.ebay.com/api-docs/static/oauth-client-credentials-grant.html +// +// The only difference from the original golang.org/x/oauth2/clientcredentials is the token type +// being forced to "Bearer". The eBay api /identity/v1/oauth2/token endpoint returns +// "Application Access Token" as token type which is then reused: +// https://github.com/golang/oauth2/blob/aaccbc9213b0974828f81aaac109d194880e3014/token.go#L68-L70 +package clientcredentials + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +type Config struct { + // ClientID is the application's ID. + ClientID string + + // ClientSecret is the application's secret. + ClientSecret string + + // TokenURL is the resource server's token endpoint + // URL. This is a constant specific to each server. + TokenURL string + + // Scope specifies optional requested permissions. + Scopes []string + + // HTTPClient used to make requests. + HTTPClient *http.Client +} + +// Token uses client credentials to retrieve a token. +func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) { + return c.TokenSource(ctx).Token() +} + +// Client returns an HTTP client using the provided token. +// The token will auto-refresh as necessary. +// The returned client and its Transport should not be modified. +func (c *Config) Client(ctx context.Context) *http.Client { + return oauth2.NewClient(ctx, c.TokenSource(ctx)) +} + +// TokenSource returns a TokenSource that returns t until t expires, +// automatically refreshing it as necessary using the provided context and the +// client ID and client secret. +// +// Most users will use Config.Client instead. +func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { + source := &tokenSource{ + ctx: ctx, + conf: c, + } + return oauth2.ReuseTokenSource(nil, source) +} + +type tokenSource struct { + ctx context.Context + conf *Config +} + +// Token refreshes the token by using a new client credentials request. +// tokens received this way do not include a refresh token +func (c *tokenSource) Token() (*oauth2.Token, error) { + v := url.Values{ + "grant_type": {"client_credentials"}, + } + if len(c.conf.Scopes) > 0 { + v.Set("scope", strings.Join(c.conf.Scopes, " ")) + } + req, err := http.NewRequest("POST", c.conf.TokenURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(c.conf.ClientID), url.QueryEscape(c.conf.ClientSecret)) + client := c.conf.HTTPClient + if client == nil { + client = http.DefaultClient + } + r, err := client.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("cannot fetch token: %v", err) + } + if code := r.StatusCode; code < 200 || code > 299 { + return nil, fmt.Errorf("%s (%d)", req.URL, r.StatusCode) + } + token := struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + }{} + if err = json.NewDecoder(bytes.NewReader(body)).Decode(&token); err != nil { + return nil, err + } + var expiry time.Time + if secs := token.ExpiresIn; secs > 0 { + expiry = time.Now().Add(time.Duration(secs) * time.Second) + } + t := oauth2.Token{ + AccessToken: token.AccessToken, + Expiry: expiry, + TokenType: "Bearer", + } + return &t, nil +} diff --git a/go.mod b/go.mod index 62a163a..6f3be34 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/jybp/ebay go 1.12 require ( + github.com/joho/godotenv v1.3.0 github.com/stretchr/testify v1.3.0 golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 ) diff --git a/go.sum b/go.sum index dfcd274..8022a3f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -12,6 +15,8 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 h1:xFEXbcD0oa/xhqQmMXztdZ0bWvexAWds+8c1gRN8nu0= golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/integration_test.go b/integration_test.go deleted file mode 100644 index fa3a07e..0000000 --- a/integration_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// +build integration - -package ebay_test - -import ( - "context" - _ "github.com/joho/godotenv/autoload" - "github.com/jybp/ebay" - "golang.org/x/oauth2" - "os" - "testing" - "net/http" - "strings" - "fmt" - "bytes" - "time" - "io/ioutil" - "encoding/json" - "net/url" -) - -var client *ebay.Client - -func init() { - clientID := os.Getenv("CLIENT_ID") - clientSecret := os.Getenv("CLIENT_SECRET") - if clientID == "" || clientSecret == "" { - panic("No CLIENT_ID or CLIENT_SECRET. Tests won't run.") - } - c := &http.Client{ - Transport: &oauth2.Transport{ - Source: oauth2.ReuseTokenSource(nil, TokenSource{ - Endpoint: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", - ID: clientID, - Secret: clientSecret, - Scopes: []string{"https://api.ebay.com/oauth/api_scope"}, - Client: http.DefaultClient, - }), - Base: http.DefaultTransport, - }, - } - client = ebay.NewSandboxClient(c) -} - -type TokenSource struct { - Endpoint string - ID string - Secret string - Scopes []string - Client *http.Client -} - -func (ts TokenSource) Token() (*oauth2.Token, error) { - scopes := strings.Join(ts.Scopes, " ") - req, err := http.NewRequest(http.MethodPost, - ts.Endpoint, - strings.NewReader(fmt.Sprintf("grant_type=client_credentials&scope=%s", url.PathEscape(scopes)))) - if err != nil { - return nil, err - } - req.SetBasicAuth(ts.ID, ts.Secret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := ts.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if c := resp.StatusCode; c < 200 || c >= 300 { - return nil, fmt.Errorf("%s\nStatus:\n%d", req.URL, resp.StatusCode) - } - token := struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - }{} - if err = json.NewDecoder(bytes.NewReader(body)).Decode(&token); err != nil { - return nil, err - } - t := oauth2.Token{ - AccessToken: token.AccessToken, - TokenType: token.TokenType, - } - if secs := token.ExpiresIn; secs > 0 { - t.Expiry = time.Now().Add(time.Duration(secs) * time.Second) - } - print(t.TokenType) - print("\n") - print(t.AccessToken) - print("\n") - return &t, nil -} - -func TestAuthorization(t *testing.T) { - // TODO user token is reauired - req, err := client.NewRequest("GET", "buy/browse/v1/item_summary/search?q=drone&limit=3") - t.Log(req, err) - into := map[string]string{} - err = client.Do(context.Background(), req, &into) - t.Log(into, err) -} diff --git a/test/integration/browse_test.go b/test/integration/browse_test.go new file mode 100644 index 0000000..7c65755 --- /dev/null +++ b/test/integration/browse_test.go @@ -0,0 +1,45 @@ +// +build integration +package integration + +import ( + "context" + "os" + "testing" + + _ "github.com/joho/godotenv/autoload" + "github.com/jybp/ebay" + "github.com/jybp/ebay/clientcredentials" +) + +var client *ebay.Client + +func init() { + clientID := os.Getenv("SANDBOX_CLIENT_ID") + clientSecret := os.Getenv("SANDBOX_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + panic("No SANDBOX_CLIENT_ID or SANDBOX_CLIENT_SECRET. Tests won't run.") + } + conf := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", + Scopes: []string{"https://api.ebay.com/oauth/api_scope"}, + } + client = ebay.NewSandboxClient(conf.Client(context.Background())) +} + +func TestAuthorization(t *testing.T) { + // https://developer.ebay.com/my/api_test_tool?index=0&api=browse&call=item_summary_search__GET&variation=json + req, err := client.NewRequest("GET", "buy/browse/v1/item_summary/search?q=test") + if err != nil { + t.Fatal(err) + } + into := map[string]interface{}{} + err = client.Do(context.Background(), req, &into) + if err != nil { + t.Fatal(err) + } + if testing.Verbose() { + t.Log(into) + } +}