LimJee
LimJee

Reputation: 51

Appstore server notification set up [Receiving App Store Server Notifications version2]

I am trying to set up my server with appstore notification. So that I can get notification when users refund their in-app-purchase. https://developer.apple.com/documentation/appstoreservernotifications/receiving_app_store_server_notifications <- guide line that I am looking now.

The version 2 response body, responseBodyV2, contains a signedPayload that’s cryptographically signed by the App Store in JSON Web Signature (JWS) format. The JWS format increases security and enables you to decode and validate the signature on your server. The notification data contains transaction and subscription renewal information that the App Store signs in JWS. The App Store Server API and the StoreKit In-App Purchase API use the same JWS-signed format for transaction and subscription status information. For more information about JWS, see the IETF RFC 7515 specification.

according to article, seem like I have to hold a signedpayload code inside a url that I am shared with my App Store Connect. https://gist.github.com/atpons/5279af568cb7d1b101247c02f0a022af <- thinking code would be look like this
So my question is,

Do I need to make some new private key and share with server developers ? look like we store the key from here https://www.apple.com/certificateauthority/ and use it whenever we request? how do I get notification? should I just expect that expected response json structure this kind of notification will come to url that I am shared with my App Store Connect. Thank you for reading my question!

Upvotes: 5

Views: 4950

Answers (3)

zangw
zangw

Reputation: 48536

Here are the steps to parse App Store Server Notifications V2

  • Extract header from JWS token
  • Verify header with an app store key
  • Extract the public key from the token to parse payload data
  • Prepare structures to bind notification
  • Parse payload and bind it to structures

And the sample codes with Golang

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")
    }
}

Usage Sample

payload := &NotificationPayload{}
cert := Cert{}

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


For more details, please refer to https://github.com/richzw/appstore

Upvotes: 0

Văn Nguyễn
Văn Nguyễn

Reputation: 61

I'm follow by steps:

  1. Get Apple Root Certificates: wget https://www.apple.com/certificateauthority/AppleRootCA-G3.cer
  2. Create pem file: openssl x509 -inform der -in ./AppleRootCA-G3.cer -out ./apple_root.pem
  3. Run code:

import { X509Certificate } from 'crypto'
import fs from 'fs'
import jwt from 'jsonwebtoken'

// parameter
const signedPayloadFile = 'path to signedPayload file, ex: /home/vannguyen/signedPayload.txt'
const appleRootPemFile = 'path to pem file in step 2, ex: /home/vannguyen/apple_root.pem'
// end

const signedPayload = fs.readFileSync(signedPayloadFile).toString()

const decodeToken = (token, segment) => {
  const tokenDecodablePart = token.split('.')[segment]
  const decoded = Buffer.from(tokenDecodablePart, 'base64').toString()
  return decoded
}

const { alg, x5c } = JSON.parse(decodeToken(signedPayload, 0))

const x5cCertificates = x5c.map(
  (header) => new X509Certificate(Buffer.from(header, 'base64'))
)
const appleRootCertificate = new X509Certificate(
  fs.readFileSync(appleRootPemFile)
)

const checkIssued = appleRootCertificate.checkIssued(
  x5cCertificates[x5cCertificates.length - 1]
)
if (!checkIssued) {
  throw new Error('Invalid token')
}

x5cCertificates.push(appleRootCertificate)

const verifierStatuses = x5cCertificates.map((x590, index) => {
  if (index >= x5cCertificates.length - 1) return true
  return x590.verify(x5cCertificates[index + 1].publicKey)
})
if (verifierStatuses.includes(false)) {
  throw new Error('Invalid token')
}
const { publicKey } = x5cCertificates[0]
const payload = JSON.parse(decodeToken(signedPayload, 1))

const transactionInfo = jwt.verify(
  payload.data.signedTransactionInfo,
  publicKey,
  {
    algorithms: alg
  }
)

console.log('transactionInfo: ', transactionInfo)

const transactionRenewalInfo = jwt.verify(
  payload.data.signedRenewalInfo,
  publicKey,
  {
    algorithms: alg
  }
)

console.log('transactionRenewalInfo: ', transactionRenewalInfo)

Upvotes: 6

Yadigar ZENGİN
Yadigar ZENGİN

Reputation: 228

Well now, I might spend a day on it but I figured out and composed a working NodeJS code finally between a bunch of Java and Ruby code snippets. Hope someone else can get benefit from it.

async processAppleNotification(signedPayload: string) {
  // Here we start with importing Apple's precious Root Certificate
  // With help of NodeJS crypto's X509Certificate constructor which only works after Node15.X but to make sure I'm using Node16.17.0
  const appleRootCertificate = new X509Certificate(
    fs.readFileSync(
      path.join(__dirname, '../../../src/assets/AppleRootCAG3.cer'),
    ),
  );

  // Decode payload unsafely, we need to get header base64 values
  let decodedPayload = await this.appleJWTService.decode(signedPayload, {
    complete: true,
  });
  if (typeof decodedPayload === 'string') {
    // Just to make sure, if decode has return string
    decodedPayload = JSON.parse(decodedPayload);
  }

  const decodedHeaders = decodedPayload['header'];
  const x5cHeaders = decodedHeaders['x5c'];
  // Map all the x5c header array values, and get them as Base64 Decoded Buffer and create X509 Cert.
  const decodedX5CHeaders: X509Certificate[] = x5cHeaders.map((_header) => {
    return new X509Certificate(Buffer.from(_header, 'base64'));
  });

  // We already know the last certificate which we receive in x5c header is AppleRootCertificate
  if (!appleRootCertificate.checkIssued(
      decodedX5CHeaders[decodedX5CHeaders.length - 1],
    )) {
    throw new UnauthorizedException();
  }
  decodedX5CHeaders.push(appleRootCertificate);

  // Let's verify all the chain together, if there is any corrupted certificate
  const verificationStatuses = [];
  decodedX5CHeaders.forEach((_header, index) => {
    if (index >= decodedX5CHeaders.length - 1) {
      return;
    }
    verificationStatuses.push(
      // Verify function returns boolean
      _header.verify(decodedX5CHeaders[index + 1].publicKey),
    );
  });

  // Check the status array, if everything is okey
  if (verificationStatuses.includes(false)) {
    throw new UnauthorizedException();
  }

  // This part is a bit Critical one, I couldn't find another way to convert X509 public key to string so here we are
  // Library name is 'jwk-to-pem', for TS please use with import * as JWKPM import statement
  const publicKeyToPEM = JWKtoPem(
    decodedX5CHeaders[0].publicKey.export({
      format: 'jwk'
    }),
  );
  const verifiedPayload = await this.appleJWTService.verify(signedPayload, {
    algorithms: [decodedHeaders.alg],
    publicKey: publicKeyToPEM,
  });
  // Here we go, all validated and have the actual payload as validated
  console.log(verifiedPayload);
}

Upvotes: 1

Related Questions