Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,18 @@ Query constructor marketplace arguments are retained only for compatibility and

Creators API credentials are region-group scoped. Use credentials that match the marketplace group you call.

| Credential Version | Region group | Example marketplaces |
|---|---|---|
| `2.1` | North America | `www.amazon.com`, `www.amazon.ca`, `www.amazon.com.mx`, `www.amazon.com.br` |
| `2.2` | Europe / Middle East / India | `www.amazon.co.uk`, `www.amazon.de`, `www.amazon.fr`, `www.amazon.in`, `www.amazon.sa`, `www.amazon.ae` |
| `2.3` | Far East | `www.amazon.co.jp`, `www.amazon.sg`, `www.amazon.com.au` |
New applications typically receive **v3.x** credentials (Login with Amazon token endpoints). **v2.x** credentials use regional Cognito endpoints and a different OAuth scope and catalog `Authorization` header shape; if Associates Central still shows `2.1`/`2.2`/`2.3`, pass `creatorsapi.WithCredentialVersion("2.1")` (etc.) on `CreateClient`.

| Credential Version | Region group | Token endpoint (default) | Catalog `Authorization` |
|---|---|---|---|
| `3.1` | North America | `https://api.amazon.com/auth/o2/token` | `Bearer <token>` |
| `3.2` | Europe / Middle East / India | `https://api.amazon.co.uk/auth/o2/token` | `Bearer <token>` |
| `3.3` | Far East | `https://api.amazon.co.jp/auth/o2/token` | `Bearer <token>` |
| `2.1` | North America | `https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token` | `Bearer <token>, Version 2.1` |
| `2.2` | Europe / Middle East / India | `https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token` | `Bearer <token>, Version 2.2` |
| `2.3` | Far East | `https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token` | `Bearer <token>, Version 2.3` |

Example marketplace groups (same as before): NA — `www.amazon.com`, `www.amazon.ca`, `www.amazon.com.mx`, `www.amazon.com.br`; EU — `www.amazon.co.uk`, `www.amazon.de`, `www.amazon.fr`, `www.amazon.in`, `www.amazon.sa`, `www.amazon.ae`; FE — `www.amazon.co.jp`, `www.amazon.sg`, `www.amazon.com.au`.

If token acquisition fails with an auth error, verify that your credential version and marketplace group match.

Expand All @@ -83,7 +90,7 @@ Recommended client behavior:

### Getting Creators API credentials

Only the primary Amazon Associates account owner can mint credentials. From [Associates Central][creatorsapi-portal] go to **Tools** → **Creators API** → **Create Application**, then **Add New Credential**. Copy the Credential Secret immediately — it is only shown once. Note the **Credential Version** (`2.1` for North America, `2.2` for Europe, `2.3` for Far East) — you'll need it if you call multiple regions from the same process.
Only the primary Amazon Associates account owner can mint credentials. From [Associates Central][creatorsapi-portal] go to **Tools** → **Creators API** → **Create Application**, then **Add New Credential**. Copy the Credential Secret immediately — it is only shown once. Note the **Credential Version** shown for your credential (`3.1`/`3.2`/`3.3` for Login with Amazon, or legacy `2.1`/`2.2`/`2.3` for Cognito). You'll need it if you call multiple regions from the same process or if your credential region group does not match the configured marketplace.

### Quick migration checklist

Expand All @@ -94,7 +101,7 @@ Use this path when migrating existing PA-API v5 call sites:
3. Configure marketplace on `Server`/`Client` (`WithMarketplace`) and do not rely on per-query marketplace arguments.
4. Replace V1 offers usage with OffersV2 (`EnableOffersV2`; `EnableOffers` remains as a compatibility alias).
5. Remove expectations around `Merchant`, `OfferCount`, and `PartnerType` request effects; these are ignored.
6. Confirm Credential Version (`2.1`/`2.2`/`2.3`) matches the marketplace group you call.
6. Confirm Credential Version matches the marketplace group you call (`3.1`/`3.2`/`3.3` by default per marketplace, or `2.1`/`2.2`/`2.3` for legacy Cognito credentials via `WithCredentialVersion`).
7. Add retry and rate-limit control for `429` and transient `5xx` responses.
8. Run local verification with your project's standard test/lint workflow before opening a PR.

Expand All @@ -103,28 +110,28 @@ Use this path when migrating existing PA-API v5 call sites:
### Create a server configuration

```go
sv := creatorsapi.New() // default: US marketplace, NA credential version 2.1
sv := creatorsapi.New() // default: US marketplace, NA credential version 3.1 (Login with Amazon)
fmt.Println("Marketplace:", sv.Marketplace())
fmt.Println("CredentialVersion:", sv.CredentialVersion())
fmt.Println("URL:", sv.URL(creatorsapi.GetItems.Path()))
// Output:
// Marketplace: www.amazon.com
// CredentialVersion: 2.1
// CredentialVersion: 3.1
// URL: https://creatorsapi.amazon/catalog/v1/getItems
```

For another marketplace:

```go
sv := creatorsapi.New(creatorsapi.WithMarketplace(creatorsapi.LocaleJapan)) // Japan -> credential version 2.3
sv := creatorsapi.New(creatorsapi.WithMarketplace(creatorsapi.LocaleJapan)) // Japan -> credential version 3.3
fmt.Println("Marketplace:", sv.Marketplace())
fmt.Println("CredentialVersion:", sv.CredentialVersion())
// Output:
// Marketplace: www.amazon.co.jp
// CredentialVersion: 2.3
// CredentialVersion: 3.3
```

The credential version is auto-derived from the configured marketplace's region group. Override it explicitly with `creatorsapi.WithCredentialVersion("2.2")` when needed.
The credential version is auto-derived from the configured marketplace's region group (`3.1`/`3.2`/`3.3`). Override with `creatorsapi.WithCredentialVersion("2.2")` when using legacy Cognito credentials (`2.1`/`2.2`/`2.3`), or when your credential group differs from the marketplace default.

### Create a client

Expand Down
35 changes: 26 additions & 9 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package paapi5

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand All @@ -15,8 +16,10 @@ import (
)

const (
// oauthScope is the scope requested when fetching an access token.
oauthScope = "creatorsapi/default"
// oauthScopeCognito is the scope for v2.x (Cognito /oauth2/token).
oauthScopeCognito = "creatorsapi/default"
// oauthScopeLWA is the scope for v3.x (Login with Amazon /auth/o2/token).
oauthScopeLWA = "creatorsapi::default"
// oauthGrantType is the OAuth2 grant type used for the client_credentials flow.
oauthGrantType = "client_credentials"
// oauthTokenLeewaySeconds keeps a small buffer before the announced expiration
Expand All @@ -38,6 +41,7 @@ type tokenManager struct {
endpoint string
clientID string
clientSecret string
lwa bool

mu sync.Mutex
accessToken string
Expand All @@ -46,7 +50,7 @@ type tokenManager struct {

// newTokenManager constructs a tokenManager. httpClient may be nil, in which
// case http.DefaultClient is used.
func newTokenManager(httpClient *http.Client, endpoint, clientID, clientSecret string) *tokenManager {
func newTokenManager(httpClient *http.Client, endpoint, clientID, clientSecret string, lwa bool) *tokenManager {
if httpClient == nil {
httpClient = http.DefaultClient
}
Expand All @@ -55,6 +59,7 @@ func newTokenManager(httpClient *http.Client, endpoint, clientID, clientSecret s
endpoint: endpoint,
clientID: clientID,
clientSecret: clientSecret,
lwa: lwa,
}
}

Expand Down Expand Up @@ -96,15 +101,23 @@ func (t *tokenManager) refreshLocked(ctx context.Context) error {
}
form := url.Values{}
form.Set("grant_type", oauthGrantType)
form.Set("client_id", t.clientID)
form.Set("client_secret", t.clientSecret)
form.Set("scope", oauthScope)
if t.lwa {
form.Set("scope", oauthScopeLWA)
} else {
form.Set("client_id", t.clientID)
form.Set("client_secret", t.clientSecret)
form.Set("scope", oauthScopeCognito)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.endpoint, strings.NewReader(form.Encode()))
if err != nil {
return errs.Wrap(err, errs.WithContext("endpoint", t.endpoint))
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if t.lwa {
basic := base64.StdEncoding.EncodeToString([]byte(t.clientID + ":" + t.clientSecret))
req.Header.Set("Authorization", "Basic "+basic)
}
resp, err := t.httpClient.Do(req)
if err != nil {
return errs.Wrap(err, errs.WithContext("endpoint", t.endpoint))
Expand Down Expand Up @@ -163,9 +176,13 @@ func truncateForLog(b []byte, max int) string {
return string(b[:max]) + "...(truncated)"
}

// authorizationHeader returns the value of the Authorization header expected
// by the Creators API: `Bearer <token>, Version <version>`.
func authorizationHeader(token, version string) string {
// authorizationHeader returns the Creators API catalog Authorization header.
// v3.x (Login with Amazon) tokens use `Bearer <token>` only; v2.x Cognito
// tokens append `, Version <version>`.
func authorizationHeader(token, version string, lwa bool) string {
if lwa {
return "Bearer " + token
}
b := strings.Builder{}
b.WriteString("Bearer ")
b.WriteString(token)
Expand Down
3 changes: 2 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type client struct {
credentialSecret string
version string
authEndpoint string
lwaFlow bool
auth *tokenManager
}

Expand Down Expand Up @@ -98,7 +99,7 @@ func (c *client) post(ctx context.Context, cmd Operation, payload []byte) ([]byt
fetch.WithRequestHeaderSet("Accept", c.server.Accept()),
fetch.WithRequestHeaderSet("Content-Type", c.server.ContentType()),
fetch.WithRequestHeaderSet(marketplaceHeader, c.server.Marketplace()),
fetch.WithRequestHeaderSet("Authorization", authorizationHeader(token, c.version)),
fetch.WithRequestHeaderSet("Authorization", authorizationHeader(token, c.version, c.lwaFlow)),
)
if err != nil {
return nil, errs.Wrap(err, errs.WithContext("url", u.String()), errs.WithContext("payload", string(payload)))
Expand Down
69 changes: 62 additions & 7 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package paapi5

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -58,7 +59,7 @@ func TestClientBasics(t *testing.T) {
if !ok {
t.Fatalf("Client is not *client: %T", c)
}
if got, want := cc.version, CredentialVersionNA; got != want {
if got, want := cc.version, CredentialVersionNAv3; got != want {
t.Errorf("client.version = %q, want %q", got, want)
}
}
Expand All @@ -73,18 +74,30 @@ func TestClientRequestSendsExpectedHeadersAndBody(t *testing.T) {
if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
t.Errorf("token Content-Type = %q, want %q", got, want)
}
authz := r.Header.Get("Authorization")
if !strings.HasPrefix(authz, "Basic ") {
t.Fatalf("token Authorization = %q, want Basic …", authz)
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authz, "Basic "))
if err != nil {
t.Fatalf("decode Basic auth: %v", err)
}
if got, want := string(raw), "credID:credSecret"; got != want {
t.Errorf("Basic credentials = %q, want %q", got, want)
}
body, _ := io.ReadAll(r.Body)
form := string(body)
for _, want := range []string{
"grant_type=client_credentials",
"client_id=credID",
"client_secret=credSecret",
"scope=creatorsapi%2Fdefault",
"scope=creatorsapi%3A%3Adefault",
} {
if !strings.Contains(form, want) {
t.Errorf("token request body %q missing %q", form, want)
}
}
if strings.Contains(form, "client_id=") || strings.Contains(form, "client_secret=") {
t.Errorf("LwA token request body must not embed client_id/client_secret: %q", form)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "tok-abc",
"expires_in": 3600,
Expand All @@ -101,7 +114,7 @@ func TestClientRequestSendsExpectedHeadersAndBody(t *testing.T) {
if got, want := r.URL.Path, "/catalog/v1/getItems"; got != want {
t.Errorf("api path = %q, want %q", got, want)
}
if got, want := r.Header.Get("Authorization"), "Bearer tok-abc, Version 2.1"; got != want {
if got, want := r.Header.Get("Authorization"), "Bearer tok-abc"; got != want {
t.Errorf("Authorization header = %q, want %q", got, want)
}
if got, want := r.Header.Get(marketplaceHeader), "www.amazon.com"; got != want {
Expand Down Expand Up @@ -151,6 +164,45 @@ func TestClientRequestSendsExpectedHeadersAndBody(t *testing.T) {
}
}

// TestClientLegacyCognitoCredentialVersion verifies v2.x Cognito credentials:
// client id/secret in the POST body, slash scope, and Version on catalog calls.
func TestClientLegacyCognitoCredentialVersion(t *testing.T) {
tokenHandler := func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Header.Get("Authorization"), ""; got != want {
t.Errorf("Cognito token request should not send Authorization header, got %q", got)
}
body, _ := io.ReadAll(r.Body)
form := string(body)
for _, want := range []string{
"grant_type=client_credentials",
"client_id=id",
"client_secret=secret",
"scope=creatorsapi%2Fdefault",
} {
if !strings.Contains(form, want) {
t.Errorf("token body %q missing %q", form, want)
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "tok-v2",
"expires_in": 3600,
})
}
apiHandler := func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Header.Get("Authorization"), "Bearer tok-v2, Version 2.2"; got != want {
t.Errorf("Authorization = %q, want %q", got, want)
}
_, _ = w.Write([]byte("{}"))
}
_, _, sv := newServers(t, tokenHandler, apiHandler)
c := sv.CreateClient("tag", "id", "secret", WithCredentialVersion(CredentialVersionEU))

q := stubQuery{op: GetItems, payload: []byte("{}")}
if _, err := c.RequestContext(context.Background(), q); err != nil {
t.Fatalf("RequestContext: %v", err)
}
}

func TestClientTokenRefreshAfterExpiry(t *testing.T) {
var tokenCalls int32
tokenHandler := func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -300,12 +352,15 @@ func TestClientNilQueryError(t *testing.T) {
}

func TestAuthorizationHeader(t *testing.T) {
if got, want := authorizationHeader("abc", "2.1"), "Bearer abc, Version 2.1"; got != want {
if got, want := authorizationHeader("abc", "2.1", false), "Bearer abc, Version 2.1"; got != want {
t.Errorf("authorizationHeader = %q, want %q", got, want)
}
if got, want := authorizationHeader("abc", ""), "Bearer abc"; got != want {
if got, want := authorizationHeader("abc", "", false), "Bearer abc"; got != want {
t.Errorf("authorizationHeader (no version) = %q, want %q", got, want)
}
if got, want := authorizationHeader("abc", "3.1", true), "Bearer abc"; got != want {
t.Errorf("authorizationHeader (LwA) = %q, want %q", got, want)
}
}

/* Copyright 2019,2020 Spiegel
Expand Down
4 changes: 2 additions & 2 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func ExampleServer() {
// Marketplace: www.amazon.com
// Region: us-east-1
// AcceptLanguage: en_US
// CredentialVersion: 2.1
// CredentialVersion: 3.1
// URL: https://creatorsapi.amazon/catalog/v1/getItems
}

Expand All @@ -34,7 +34,7 @@ func ExampleNew() {
// Marketplace: www.amazon.co.jp
// Region: us-west-2
// AcceptLanguage: ja_JP
// CredentialVersion: 2.3
// CredentialVersion: 3.3
// URL: https://creatorsapi.amazon/catalog/v1/getItems
}

Expand Down
Loading
Loading