Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] support App Store Server API #145

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,20 @@ 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
go get github.com/awa/go-iap/amazon
go get github.com/awa/go-iap/hms
```


# Quick Start

### In App Purchase (via App Store)
Expand Down Expand Up @@ -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).

Expand Down
106 changes: 106 additions & 0 deletions appstore/storekit/client.go
Original file line number Diff line number Diff line change
@@ -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)
}
67 changes: 67 additions & 0 deletions appstore/storekit/purchase.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down