Reputation: 51
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
Reputation: 48536
Here are the steps to parse App Store Server Notifications V2
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
Reputation: 61
I'm follow by steps:
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
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