Golang — Handling Appstore Server-to-Server V2 Notifications

Learn how to decode SNS notifications v2
Jun 7 2022 · 7 min read

Introduction 

App Store has released server-to-server notification version 2 with more events(notification types and sub-types) than version 1.

In version 2, they are sending JSON Web Signature (JWS) formatted (Which is a JWT token) notification to improve the security of data.

In this article, we will show how to decode SNS notifications v2 in a Go application.

Background

When I started implementing app store SNS v2 in golang, I was a bit confused about how to verify and parse JWS data.

Thanks to this thread, I learned lots of important things there. While doing the implementation, I simplified it to make understanding easier.

This article will help you learn about SNS implementation in a very simple way.

Before beginning with the actual implementation, let’s get familiar with the request body of the notification we get from Apple first!

We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!
 

Notification V2 request body

{ 
  "signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFF..."
}

It’s signed by the App Store and contains all required data including transaction info and renewal info of app store purchases.

FYI : You can parse and review signedPayload on jwt.io

Whenever we receive a notification from the app store, we need to first verify the request and then we need to parse that token to get payload data.

JWS contains 3 parts in the token separated by .

  1. Header (contains two fields : Algorithm and Token type)
  2. Payload (contains data)
  3. Signature

For verifying the request we need to parse the header part. If it's verified successfully, then we will parse the payload otherwise decline it.

We have divided the decoding process of signedPayload in the following steps —

  1. Extract header from JWS token
  2. Verify the header with an app store key
  3. Extract the public key from the token to parse payload data
  4. Prepare structures to bind notification
  5. Parse payload and bind it to structures

Let’s learn it step by step.

1. Extract header from JWS token

First, we will define the structure to get a header with two fields algorithm and token type.

  • Algorithm: ES256
  • Token type: x5c (X.509 certificate chain) contains the X.509 public key certificate or certificate chain [RFC5280] corresponding to the key used to digitally sign the JWS.
// prepare header structure contains algorithm and token type
type NotificationHeader struct {
	Alg string   `json:"alg"`
	X5c []string `json:"x5c"`
}

// extract header from given JWS formatted token
func extractHeaderByIndex(tokenStr string, index int) ([]byte, error) {

	tokenArr := strings.Split(tokenStr, ".")  // get header from token
  
	headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0])  //convert header to byte
	if err != nil {
		return nil, err
	}

	var header NotificationHeader
	err = json.Unmarshal(headerByte, &header)  // bind byte to header structure
	if err != nil {
		return nil, err
	}

	certByte, err := base64.StdEncoding.DecodeString(header.X5c[index])  //decode x.509 cerificate headers to byte
	if err != nil {
		return nil, err
	}

	return certByte, nil
}

Let’s understand it,

  • strings.Split: split the string by . and extract the first part of the split array which is a header.
  • base64.RawStdEncoding.DecodeString(tokenArr[0]) decodes the base64 header string to a byte with raw encoding.
  • Unmarshal bytes to go structure which is NotificationHeader . We need to use X5c of structure to get the certificate.
  • X5c is an array of 3 elements which is a certificate chain.
    X5c[0] : use for extracting public key
    X5c[1] : intermediate certificate used to verify the header
    X5c[2] : root certificate used to verify the header

2. Verify the header with an app store key

In this step, we will verify the X5c certificates using the app store key, which we can download from Apple Root CA — G3 Root.

Convert it to PEM using the following command.

openssl x509 -in AppleRootCA-G3.cer -out cert.pem

Verify the X5c certificate using the app store key.

func verifyCert(certByte []byte, intermediateCert []byte) error {
  
	roots := x509.NewCertPool()  // new empty set of certificate pool
  
	ok := roots.AppendCertsFromPEM([]byte(APP_STORE_NOTIFICATION_ROOT_CERT))  // parse and append app store certificate to certPool
	if !ok {
		return errors.New("failed to parse root certificate")
	}
	
	interCert, err := x509.ParseCertificate(intermediateCert)  // parse and append intermediate X5c certificate
	if err != nil {
		return errors.New("failed to parse intermediate certificate")
	}
	intermediate := x509.NewCertPool()
	intermediate.AddCert(interCert)

	cert, err := x509.ParseCertificate(certByte) // parse X5c certificate 
	if err != nil {
		return err
	}
	
	opts := x509.VerifyOptions{   // append certificate pool to verify options of x509 
		Roots: roots,
		Intermediates: intermediate,
	}
	
	if _, err := cert.Verify(opts); err != nil {  // verify X5c certificate using app store certificate resides in opts
		return err
	}

	return nil
}
  • x509.NewCertPool() will create a new and empty certificate pool, and we will append the app store cert to this pool.
  • APP_STORE_NOTIFICATION_ROOT_CERT is converted PEM key from the app store certificate. Copy the PEM key from the file and assign it here.
  • roots.AppendCertsFromPEM parse and append the PEM key to the cert pool.
  • We also parse and append intermediate certificates to the certificate pool.
  • x509.ParseCertificate(certByte) parses extracted X5c certificate.
  • x509.VerifyOptions prepare x509 verify options.
  • cert.Verify(opts) verifies X5c certificate using verifyOptions.

3. Extract the public key from the token to parse payload data

Now we have verified the notification request, it's time to parse payload data. For that, we need a public key. Let’s get that from the header.

func extractPublicKeyFromToken(tokenStr string) (*ecdsa.PublicKey, error) {
  
	certStr, err := extractHeaderByIndex(tokenStr, 0)  // get certificate from X5c[0] header
	if err != nil {
		return nil, err
	}

	cert, err := x509.ParseCertificate(certStr)  // parse certificate
	if err != nil {
		return nil, err
	}

	switch pk := cert.PublicKey.(type) {   // get public key
	case *ecdsa.PublicKey:
	    return pk, nil
	default:
	    return nil, errors.New("appstore public key must be of type ecdsa.PublicKey")
        }
}

We have reused code from step 1 and step 2 and extract the public key from the header.

  • Get the certificate from extractHeaderByIndex method.
  • Parse certificate string to bytes.
  • Extract ecdsa.PublicKey from certificate bytes.

That’s it. You will have the public key now.

4. Prepare structures to bind notification

When we parsed the JWS token on jwt.io, we see that the payload has some fields. We will define go structures for required fields from those fields.

From the payload, I have identified 3 basic structures that we will require to change the user’s subscription status.

  1. NotificationPayload
  2. TransactionInfo
  3. RenewalInfo
// Notification signed payload
	type NotificationPayload struct {
		jwt.StandardClaims
		NotificationType    string           `json:"notificationType"`
		Subtype             string           `json:"subtype"`
		NotificationUUID    string           `json:"notificationUUID"`
		NotificationVersion string           `json:"notificationVersion"`
		Data                NotificationData `json:"data"`
	}

	// Notification Data
	type NotificationData struct {
		jwt.StandardClaims
		AppAppleID            int    `json:"appAppleId"`
		BundleID              string `json:"bundleId"`
		BundleVersion         string `json:"bundleVersion"`
		Environment           string `json:"environment"`
		SignedRenewalInfo     string `json:"signedRenewalInfo"`
		SignedTransactionInfo string `json:"signedTransactionInfo"`
	}

	// Notification Transaction Info
	type TransactionInfo struct {
		jwt.StandardClaims
		TransactionId               string `json:"transactionId"`
		OriginalTransactionID       string `json:"originalTransactionId"`
		WebOrderLineItemID          string `json:"webOrderLineItemId"`
		BundleID                    string `json:"bundleId"`
		ProductID                   string `json:"productId"`
		SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"`
		PurchaseDate                int    `json:"purchaseDate"`
		OriginalPurchaseDate        int    `json:"originalPurchaseDate"`
		ExpiresDate                 int    `json:"expiresDate"`
		Type                        string `json:"type"`
		InAppOwnershipType          string `json:"inAppOwnershipType"`
		SignedDate                  int    `json:"signedDate"`
		Environment                 string `json:"environment"`
	}

	// Notification Renewal Info
	type RenewalInfo struct {
		jwt.StandardClaims
		OriginalTransactionID  string `json:"originalTransactionId"`
		ExpirationIntent       int    `json:"expirationIntent"`
		AutoRenewProductId     string `json:"autoRenewProductId"`
		ProductID              string `json:"productId"`
		AutoRenewStatus        int    `json:"autoRenewStatus"`
		IsInBillingRetryPeriod bool   `json:"isInBillingRetryPeriod"`
		SignedDate             int    `json:"signedDate"`
		Environment            string `json:"environment"`
	}

SNS v2 has included notification types along with subtypes to get detailed information about subscription status.

You can understand all the fields on the app store’s official documentation.

You have noticed jwt.StandardClaims in all the structures as we need them to parse JWS data.

Let’s go to the final step.

5. Parse payload and bind it with structures

/** payload data **/
payload := &NotificationPayload{}

_, err = jwt.ParseWithClaims(tokenStr, payload, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
})


/** transaction info **/
transactionInfo := &TransactionInfo{}
tokenStr := payload.Data.SignedTransactionInfo

_, err = jwt.ParseWithClaims(tokenStr, transactionInfo, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
})


/** renewal info **/
renewalInfo := &RenewalInfo{}
tokenStr := payload.Data.SignedRenewalInfo

_, err = jwt.ParseWithClaims(tokenStr, renewalInfo, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
})

Here, We have used the public key method from step 3 to parse the JWS token string.

  • In notificationPayload, we have parsed the main signedPayload string. It has a data field, which contains transactionInfo and renewalInfo in JWS format string.
  • In TransactionInfo, we have parsed data.transactionInfo string.
  • In renewalInfo, we have parsed data.renewalInfo string.

Final method

The above steps are used to decode the notification payload signed by the app store.

But we have to do that in order as we have discussed earlier, if the certificate is verified, then only we will parse the data otherwise decline it.

func parseJwtSignedData(tokenStr string) error {

	rootCertStr, err := extractHeaderByIndex(tokenStr, 2) // get root cert from X5c header
	if err != nil {
		return err
	}

	intermediateCertStr, err := extractHeaderByIndex(tokenStr, 1) // get intermediate cert from X5c header
	if err != nil {
		return err
	}

	/** verify certs. if not verified, return err **/
	if err = verifyCert(rootCertStr, intermediateCertStr); err != nil {  
		return err
	}

	/** payload data **/
	payload := &NotificationPayload{}

	_, err = jwt.ParseWithClaims(tokenStr, payload, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
	})


	/** transaction info **/
	transactionInfo := &TransactionInfo{}
	tokenStr = payload.Data.SignedTransactionInfo

	_, err = jwt.ParseWithClaims(tokenStr, transactionInfo, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
	})


	/** renewal info **/
	renewalInfo := &RenewalInfo{}
	tokenStr = payload.Data.SignedRenewalInfo

	_, err = jwt.ParseWithClaims(tokenStr, renewalInfo, func(token *jwt.Token) (interface{}, error) {
		return extractPublicKeyFromToken(tokenStr)
	})

	return nil
}

Now we have payload, transactionInfo and renewalInfo, Using these data we can update the user’s subscription status based on the notificationType and it’s subType .

Conclusion

That’s it for today. Hope you have an understanding of how we can decode SNS v2 notification data and use it on the server. Similar way, you can implement SNS v2 in any backend language like Ruby, PHP, or Python.

You can refer to app store documentation to decide what user’s data you have to update at your backend from notification.

We’re Grateful to have you with us on this journey!

Suggestions and feedback are more than welcome! 

Please reach us at Canopas Twitter handle @canopas_eng with your content or feedback. Your input enriches our content and fuels our motivation to create more valuable and informative articles for you.


sumita-k image
Sumita Kevat
Sumita is an experienced software developer with 5+ years in web development. Proficient in front-end and back-end technologies for creating scalable and efficient web applications. Passionate about staying current with emerging technologies to deliver.


sumita-k image
Sumita Kevat
Sumita is an experienced software developer with 5+ years in web development. Proficient in front-end and back-end technologies for creating scalable and efficient web applications. Passionate about staying current with emerging technologies to deliver.

canopas-logo
We build products that customers can't help but love!
Get in touch

Whether you need...

  • *
    High-performing mobile apps
  • *
    Bulletproof cloud solutions
  • *
    Custom solutions for your business.
Bring us your toughest challenge and we'll show you the path to a sleek solution.
Talk To Our Experts
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.