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.
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!
{
"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 .
Algorithm
and Token type
)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 —
First, we will define the structure to get a header with two fields algorithm and token type.
// 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,
.
and extract the first part of the split array which is a header.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 keyX5c[1]
: intermediate certificate used to verify the headerX5c[2]
: root certificate used to verify the headerIn 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
}
PEM key
from the app store certificate. Copy the PEM key from the file and assign it here.X5c
certificate.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.
extractHeaderByIndex
method.ecdsa.PublicKey
from certificate bytes.That’s it. You will have the public key now.
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.
// 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.
/** 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.
signedPayload
string. It has a data
field, which contains transactionInfo
and renewalInfo
in JWS format string.data.transactionInfo
string.data.renewalInfo
string.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
.
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.
Whether you need...