diff --git a/README.md b/README.md index 7b95130..8c39ecc 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ go-iap go-iap verifies the purchase receipt via AppStore, GooglePlayStore or Amazon AppStore. Current API Documents: - * AppStore: [![GoDoc](https://godoc.org/github.com/awa/go-iap/appstore?status.svg)](https://godoc.org/github.com/awa/go-iap/appstore) * GooglePlay: [![GoDoc](https://godoc.org/github.com/awa/go-iap/playstore?status.svg)](https://godoc.org/github.com/awa/go-iap/playstore) * Amazon AppStore: [![GoDoc](https://godoc.org/github.com/awa/go-iap/amazon?status.svg)](https://godoc.org/github.com/awa/go-iap/amazon) * Huawei HMS: [![GoDoc](https://godoc.org/github.com/awa/go-iap/hms?status.svg)](https://godoc.org/github.com/awa/go-iap/hms) - # Installation + ``` go get github.com/awa/go-iap/appstore go get github.com/awa/go-iap/playstore @@ -23,7 +22,6 @@ go get github.com/awa/go-iap/amazon go get github.com/awa/go-iap/hms ``` - # Quick Start ### In App Purchase (via App Store) @@ -98,17 +96,22 @@ func main() { ``` # ToDo -- [x] Validator for In App Purchase Receipt (AppStore) +- [x] Validator for In App Purchase Receipt (App Store) - [x] Validator for Subscription token (GooglePlay) - [x] Validator for Purchase Product token (GooglePlay) +- [x] App Store Server API (supported sandbox environment only NOW) + - [x] In-App Purchase History + - [ ] Subscription Status + - [ ] In-App Purchase Consumption - [ ] More Tests - # Support ### In App Purchase This validator supports the receipt type for iOS7 or above. +Support [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi) + ### In App Billing This validator uses [Version 3 API](http://developer.android.com/google/play/billing/api.html). diff --git a/appstore/storekit/client.go b/appstore/storekit/client.go new file mode 100644 index 0000000..2ba7206 --- /dev/null +++ b/appstore/storekit/client.go @@ -0,0 +1,106 @@ +package storekit + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "github.com/dgrijalva/jwt-go" + "github.com/google/uuid" + "io/ioutil" + "net/http" + "time" +) + +const ( + // SandboxURL is the endpoint for sandbox environment. + SandboxURL string = "https://api.storekit-sandbox.itunes.apple.com/inApps/v1" + // ProductionURL is the endpoint for production environment. + ProductionURL string = "https://api.storekit.itunes.apple.com/inApps/v1" + + // tokenExpire To get better performance from the App Store Server API, reuse the same signed token for up to 60 minutes. + tokenExpire = 3600 +) + +type Client struct { + BundleID string // your app bundleID + IssuerID string // To generate token first, see: https://developer.apple.com/documentation/appstoreserverapi/creating_api_keys_to_use_with_the_app_store_server_api + PrivateKey *ecdsa.PrivateKey // same as above + token *jwt.Token // jwt token for requests, see: https://developer.apple.com/documentation/appstoreserverapi/generating_tokens_for_api_requests + Sandbox bool // default is production + + signedLatest int64 // latest sign time + signedToken string // latest sign token +} + +func parsePrivateKey(bytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(bytes) + if block == nil { + return nil, errors.New("AuthKey must be a valid .p8 PEM file") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + + switch pk := key.(type) { + case *ecdsa.PrivateKey: + return pk, nil + default: + return nil, errors.New("AuthKey must be of type ecdsa.PrivateKey") + } +} + +// New return a client for App Store Server API +func New(issuerID, keyID string, privateKey []byte, bundleId string) (*Client, error) { + // parse privateKey + key, err := parsePrivateKey(privateKey) + if err != nil { + return nil, err + } + + token := jwt.New(jwt.SigningMethodES256) + token.Header["kid"] = keyID + + return &Client{ + IssuerID: issuerID, + BundleID: bundleId, + PrivateKey: key, + token: token, + }, nil +} + +func (client *Client) setToken(req *http.Request) { + now := time.Now().Unix() + if now-client.signedLatest > tokenExpire { + client.token.Claims = jwt.MapClaims{ + "iss": client.IssuerID, + "iat": now, + "exp": now + tokenExpire, + "aud": "appstoreconnect-v1", + "nonce": uuid.New().String(), + "bid": client.BundleID, + } + client.signedLatest = now + client.signedToken, _ = client.token.SignedString(client.PrivateKey) + } + req.Header.Set("Authorization", "Bearer "+client.signedToken) +} + +func (client *Client) Do(req *http.Request, resp interface{}) error { + client.setToken(req) + + raw, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer raw.Body.Close() + + body, err := ioutil.ReadAll(raw.Body) + if err != nil { + return err + } + // TODO parse apple error response + return json.Unmarshal(body, resp) +} diff --git a/appstore/storekit/purchase.go b/appstore/storekit/purchase.go new file mode 100644 index 0000000..0190ab8 --- /dev/null +++ b/appstore/storekit/purchase.go @@ -0,0 +1,67 @@ +package storekit + +import ( + "github.com/dgrijalva/jwt-go" + "net/http" + "net/url" +) + +// HistoryResponse https://developer.apple.com/documentation/appstoreserverapi/historyresponse +type HistoryResponse struct { + AppAppleId int64 `json:"appAppleId"` // The app’s identifier in the App Store. + BundleId string `json:"bundleId"` // The bundle identifier of the app. + Environment string `json:"environment"` // The server environment in which you’re making the request, sandbox or production. + HasMore bool `json:"hasMore"` // A Boolean value that indicates whether App Store has more transactions than are returned in this request. + Revision string `json:"revision"` // A token you use in a query to request the next set transactions from the Get Transaction History endpoint. + SignedTransactions []string `json:"signedTransactions"` // An array of in-app purchase transactions for the customer, signed by Apple, in JSON Web Signature format. + DecodeTransactions []JWSTransactionDecodedPayload +} + +// JWSTransactionDecodedPayload https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload +type JWSTransactionDecodedPayload struct { + jwt.StandardClaims + BundleId string `json:"bundleId"` + ExpiresDate int64 `json:"expiresDate"` + InAppOwnershipType string `json:"inAppOwnershipType"` + OfferType int `json:"offerType"` + OriginalPurchaseDate int64 `json:"originalPurchaseDate"` + OriginalTransactionId string `json:"originalTransactionId"` + ProductId string `json:"productId"` + PurchaseDate int64 `json:"purchaseDate"` + Quantity int `json:"quantity"` + SignedDate int64 `json:"signedDate"` + SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` + TransactionId string `json:"transactionId"` + Type string `json:"type"` + WebOrderLineItemId string `json:"webOrderLineItemId"` +} + +// GetTransactionHistory Get a customer’s transaction history, including all of their in-app purchases in your app. +func (client *Client) GetTransactionHistory(originalTransactionId, revision string) (resp *HistoryResponse, err error) { + u := ProductionURL + "/history/" + originalTransactionId + if client.Sandbox { + u = SandboxURL + "/history/" + originalTransactionId + } + // add query + query := url.Values{} + if len(revision) > 0 { + query.Set("revision", revision) + } + + req, _ := http.NewRequest(http.MethodGet, u+"?"+query.Encode(), nil) + resp = new(HistoryResponse) + if err = client.Do(req, resp); err != nil { + return + } + // decode transaction + resp.DecodeTransactions = make([]JWSTransactionDecodedPayload, 0, len(resp.SignedTransactions)) + for _, raw := range resp.SignedTransactions { + var tmp = new(JWSTransactionDecodedPayload) + _, _ = jwt.ParseWithClaims(raw, tmp, func(token *jwt.Token) (interface{}, error) { + return client.PrivateKey, nil + }) + resp.DecodeTransactions = append(resp.DecodeTransactions, *tmp) + } + + return +} diff --git a/go.mod b/go.mod index 9f083e8..06009c0 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/awa/go-iap go 1.15 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/mock v1.5.0 + github.com/google/uuid v1.2.0 // indirect golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c golang.org/x/sys v0.0.0-20210608053332-aa57babbf139 // indirect diff --git a/go.sum b/go.sum index 3c7b8dd..1a912ce 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -138,6 +140,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=