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

feat(appstore): add decode notification v2 body method to appstore #200

Merged
merged 3 commits into from
May 15, 2023
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
129 changes: 129 additions & 0 deletions appstore/cert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package appstore

import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)

// rootPEM is generated through `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem`
const rootPEM = `
-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
6BgD56KyKA==
-----END CERTIFICATE-----
`

type Cert struct{}

// ExtractCertByIndex extracts the certificate from the token string by index.
func (c *Cert) extractCertByIndex(tokenStr string, index int) ([]byte, error) {
if index > 2 {
return nil, errors.New("invalid index")
}

tokenArr := strings.Split(tokenStr, ".")
headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0])
if err != nil {
return nil, err
}

type Header struct {
Alg string `json:"alg"`
X5c []string `json:"x5c"`
}
var header Header
err = json.Unmarshal(headerByte, &header)
if err != nil {
return nil, err
}

certByte, err := base64.StdEncoding.DecodeString(header.X5c[index])
if err != nil {
return nil, err
}

return certByte, nil
}

// VerifyCert verifies the certificate chain.
func (c *Cert) verifyCert(rootCert, intermediaCert, leafCert *x509.Certificate) error {
roots := x509.NewCertPool()
ok := roots.AppendCertsFromPEM([]byte(rootPEM))
if !ok {
return errors.New("failed to parse root certificate")
}

intermedia := x509.NewCertPool()
intermedia.AddCert(intermediaCert)

opts := x509.VerifyOptions{
Roots: roots,
Intermediates: intermedia,
}
_, err := rootCert.Verify(opts)
if err != nil {
return err
}

_, err = leafCert.Verify(opts)
if err != nil {
return err
}

return nil
}

func (c *Cert) ExtractPublicKeyFromToken(token string) (*ecdsa.PublicKey, error) {
rootCertBytes, err := c.extractCertByIndex(token, 2)
if err != nil {
return nil, err
}
rootCert, err := x509.ParseCertificate(rootCertBytes)
if err != nil {
return nil, fmt.Errorf("appstore failed to parse root certificate")
}

intermediaCertBytes, err := c.extractCertByIndex(token, 1)
if err != nil {
return nil, err
}
intermediaCert, err := x509.ParseCertificate(intermediaCertBytes)
if err != nil {
return nil, fmt.Errorf("appstore failed to parse intermediate certificate")
}

leafCertBytes, err := c.extractCertByIndex(token, 0)
if err != nil {
return nil, err
}
leafCert, err := x509.ParseCertificate(leafCertBytes)
if err != nil {
return nil, fmt.Errorf("appstore failed to parse leaf certificate")
}
if err = c.verifyCert(rootCert, intermediaCert, leafCert); err != nil {
return nil, err
}

switch pk := leafCert.PublicKey.(type) {
case *ecdsa.PublicKey:
return pk, nil
default:
return nil, errors.New("appstore public key must be of type ecdsa.PublicKey")
}
}
26 changes: 20 additions & 6 deletions appstore/notification_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,26 @@ type (
// SubscriptionNotificationV2DecodedPayload is struct
// https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload
SubscriptionNotificationV2DecodedPayload struct {
NotificationType NotificationTypeV2 `json:"notificationType"`
Subtype SubtypeV2 `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
NotificationVersion string `json:"version"`
SignedDate int64 `json:"signedDate"`
Data SubscriptionNotificationV2Data `json:"data"`
NotificationType NotificationTypeV2 `json:"notificationType"`
Subtype SubtypeV2 `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
NotificationVersion string `json:"version"`
SignedDate int64 `json:"signedDate"`
Data SubscriptionNotificationV2Data `json:"data,omitempty"`
Summary SubscriptionNotificationV2Summary `json:"summary,omitempty"`
}

// SubscriptionNotificationV2Summary is struct
// https://developer.apple.com/documentation/appstoreservernotifications/summary
SubscriptionNotificationV2Summary struct {
RequestIdentifier string `json:"requestIdentifier"`
Environment string `json:"environment"`
AppAppleId int64 `json:"appAppleId"`
BundleID string `json:"bundleId"`
ProductID string `json:"productId"`
StorefrontCountryCodes string `json:"storefrontCountryCodes"`
FailedCount int64 `json:"failedCount"`
SucceededCount int64 `json:"succeededCount"`
}

// SubscriptionNotificationV2Data is struct
Expand Down
17 changes: 17 additions & 0 deletions appstore/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"io/ioutil"
"net/http"
"time"

"github.com/golang-jwt/jwt/v4"
)

//go:generate mockgen -destination=mocks/appstore.go -package=mocks github.com/awa/go-iap/appstore IAPClient
Expand All @@ -26,6 +28,7 @@ const (
type IAPClient interface {
Verify(ctx context.Context, reqBody IAPRequest, resp interface{}) error
VerifyWithStatus(ctx context.Context, reqBody IAPRequest, resp interface{}) (int, error)
ParseNotificationV2(tokenStr string, result *jwt.Token) error
}

// Client implements IAPClient
Expand Down Expand Up @@ -187,3 +190,17 @@ func (c *Client) parseResponse(resp *http.Response, result interface{}, ctx cont

return r.Status, nil
}

// ParseNotificationV2 parse notification from App Store Server
func (c *Client) ParseNotificationV2(tokenStr string, result *jwt.Token) error {
cert := Cert{}

result, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
return cert.ExtractPublicKeyFromToken(tokenStr)
})
if err != nil {
return err
}

return nil
}
14 changes: 7 additions & 7 deletions playstore/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func TestNewDefaultTokenSourceClient(t *testing.T) {
func TestAcknowledgeSubscription(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand All @@ -115,7 +115,7 @@ func TestAcknowledgeSubscription(t *testing.T) {
func TestVerifySubscription(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand All @@ -131,7 +131,7 @@ func TestVerifySubscription(t *testing.T) {
func TestVerifySubscriptionV2(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand All @@ -147,7 +147,7 @@ func TestVerifySubscriptionV2(t *testing.T) {
func TestVerifyProduct(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand All @@ -163,7 +163,7 @@ func TestVerifyProduct(t *testing.T) {
func TestAcknowledgeProduct(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand All @@ -179,7 +179,7 @@ func TestAcknowledgeProduct(t *testing.T) {
func TestConsumeProduct(t *testing.T) {
t.Parallel()
// Exception scenario
expected := "googleapi: Error 400: Invalid Value, invalid"
expected := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"

client, _ := New(jsonKey)
ctx := context.Background()
Expand Down Expand Up @@ -212,7 +212,7 @@ func TestCancelSubscription(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, _ := New(jsonKey)
expectedStr := "googleapi: Error 400: Invalid Value, invalid"
expectedStr := "googleapi: Error 404: No application was found for the given package name., applicationNotFound"
actual := client.CancelSubscription(ctx, "package", "productID", "purchaseToken")

if actual == nil || actual.Error() != expectedStr {
Expand Down