본문 바로가기
개발/Node.js

[Node.js] 인앱결제 서버 개발기1 (앱스토어)

by mixxeo 2023. 7. 19.

최근에 회사 서비스에 인앱결제를 도입하면서 서버 구현을 맡았는데요,

생각보다 사전 준비나 각 스토어의 server-to-server api 호출 과정이 복잡한데 공식문서만으로는 순조롭게 진행되지 않아 시간이 꽤 걸렸습니다. 🥲

 

참고 자료 찾기가 어려워서 개발하면서 꼭 블로그에 정리해둬야겠다는 생각이 계속 들었던 작업이었습니다 ㅎㅎ

참고로 이번에는 '인앱결제로 결제를 할 수 있다'까지만 진행했고 환불 정책등은 고려하지 않았습니다.

 

 

인앱결제 플로우

서비스에서 인앱결제를 지원하기 위한 기본적인 플로우입니다.

서버단에서 구현해야하는 부분은

3~5의 인앱결제 주문건을 서비스의 주문데이터와 연결하는 작업,

8~11의 실제로 인앱결제가 진행된 주문건의 유효성을 검증하고 유저가 구매한 디지털 컨텐츠/재화를 지급하는 작업입니다.

 

이 글에서 주요하게 다룰 내용은 9번의 결제 정보 유효성 검증입니다.

 

 

사전 준비

우선 App Store API 호출에 필요한 JWT를 생성하기 위한 private key를 발급받아야 하고 app bundle id가 필요합니다.

 

Private API Key

1. App Store Connect 로그인

 

2. 나의 앱 > 사용자 및 액세스

 

3. 키 > 앱 내 구입

 

4. 키 생성 > 이름 설정

키 생성 버튼
이름 설정 > 생성

 

5. 앱 내 구입 키 다운로드(.p8 파일)

 

6. key idissuer id도 저장해둡니다.

 

7. 5번에서 다운로드 받은 .p8파일을 pem 파일로 변환

$openssl pkcs8 -in <KEY_FILE_NAME.p8> -nocrypt -out <KEY_FILE_NAME.pem>

 

App Bundle Id

1. 앱 > 인앱결제 적용할 앱 선택

 

2. 일반 정보 > 앱 정보 > 번들 ID

 

 

참고) App Store API 공식문서

 

Creating API keys to use with the App Store Server API | Apple Developer Documentation

Create API keys you use to sign JSON Web Tokens and authorize API requests.

developer.apple.com

 

 

구매 유효성 검증

App Store API 호출을 위한 사전 준비가 되었으니, 본격적으로 앱 클라이언트에서 발생한 구매 정보를 받아 구매건이 유효한지 검증하는 로직을 구현해보겠습니다.

 

기존에 ios 인앱결제 유효성 검증은 verifyReceipt API 호출로 비교적 간단하게 가능했지만,

이 API가 deprecated 되어서 TransactionInfo API로 직접 구매 정보를 불러와 verify 하는 방식으로 진행을 해야합니다.

(encrypted된 transaction info를 verify하는 방식이 명확히 제시되어있지 않아 가장 헤맨 부분입니다ㅜㅜ)

 

구현 할 때 가장 도움이 많이 된 자료는 WWDC21의 인앱결제 서버 세션입니다!

 

Generate JWT

App Store API를 호출하기 위해서는 JWT가 필요하기 때문에 앞서 준비한 private key를 이용해 JWT를 생성합니다.

참고) Generate JWT 공식 가이드

 

JWT Header

{
  "alg": "ES256",
  "kid": "2X9R4HXF34",
  "typ": "JWT"
}

- alg: token encryption algorithm.

   ES256

- kid: private key id.

   사전 준비 6번에서 저장해둔 private key id

- typ: token type.

   JWT

 

JWT Payload

{
  "iss": "57246542-96fe-1a63e053-0824d011072a",
  "iat": 1623085200,
  "exp": 1623086400,
  "aud": "appstoreconnect-v1",
  "bid": "com.example.testbundleid2021"
}

- iss: private key issuer id.

   사전 준비 6번에서 저장해둔 issuer id

- iat: issued at.

   JWT 발급 시각(second 단위)

- exp: expiration time.

   JWT 만료 시각(second 단위, 60분 이내로 설정 가능)

- aud: audience.

   appstoreconnect-v1

- bid: bundle id.

  사전준비에서 저장해둔 앱 번들 ID

 

구현 코드

import * as jwt from "jsonwebtoken";

const AppStoreTokenLifeTime = 30 * 60; // 30 minutes in seconds
const AppStoreTokenSignAlgorithm = "ES256";
const AppStoreTokenType = "JWT";
const AppStoreAudience = "appstoreconnect-v1";

interface TokenPayload {
  iss: string; // private key issuerId
  iat: number; // token issuedAt
  exp: number; // token expirationTime
  aud: string; // audience
  bid: string; // app bundleId
}

interface TokenSignOptions {
  algorithm: jwt.Algorithm;
  header: {
    alg: string;
    kid: string;
    typ: string;
  };
}
  
async function generateJWT() {
  const now = Date.now();
  const issuedAt = Math.round(now / 1000); // milliseconds to seconds
  const expirationTime = issuedAt + AppStoreTokenLifeTime;

  const privateKey = await this.getPrivateKeyFromPemFile("privateFileKey");

  const payload: TokenPayload = {
    iss: "privateKeyIssuerId",
    iat: issuedAt,
    exp: expirationTime,
    aud: AppStoreAudience,
    bid: "appBundleId",
  };

  const signOptions: TokenSignOptions = {
    algorithm: AppStoreTokenSignAlgorithm,
    header: {
      alg: AppStoreTokenSignAlgorithm,
      kid: "privateKeyId",
      typ: AppStoreTokenType,
    },
  };

  const token = jwt.sign(payload, privateKey, signOptions);
  return token;
}

async function getPrivateKeyFromPemFile(
  fileKey: string,
) {
  const file = await s3
    .getObject({
      Bucket: BUCKET,
      Key: fileKey,
    }).promise();
    
  const content = file.Body?.toLocaleString()!;
  return content.replace(/\n$/, ""); // remove only end line break.
}

jwt 생성은 npm jsonwebtoken 라이브러리를 사용했고,

사전준비 7번에서 pem 파일로 변환해둔 private key를 읽어와 jwt sign에 사용하면 됩니다.

저는 aws s3에 private key pem 파일을 저장해두고 읽어와서 사용했습니다.

 

구매 정보 가져오기 (Get TransactionInfo)

이제 생성한 JWT로 TransactionInfo API를 호출하면, transaction 데이터를 불러올 수 있습니다!

 

Transaction 이란?

참고) 공식문서

- Transaction은 유저가 인앱결제를 하거나 구독을 갱신할 때마다 생성되는 구매 데이터 입니다.

- Transaction 데이터는 결제 완료된 인앱결제건을 의미합니다.

- 각 Transaction에 대해, 서비스는 구매된 디지털 컨텐츠 및 재화를 제공하고 Transaction을 완료시키면 됩니다.

 

즉, 앱 클라이언트에서 인앱결제가 발생할 때마다 생성되는 Transaction을 하나의 결제건으로 보고,

클라이언트로부터 Transaction id를 넘겨 받아 서버에서는 해당 Transaction이 유효한지 확인해야합니다.

 

구현 코드

const APP_STORE_SERVER_API_DOMAIN =
  "https://api.storekit-sandbox.itunes.apple.com";

async function getTransactionInfo(transactionId: string) {
  const token = await generateJWT();
  const path = `inApps/v1/transactions/${transactionId}`;

  const res = await callAPI(path, token);

  return res;
}

async function callAPI(path: string, token: string) {
  const res = await axios({
    url: `${APP_STORE_SERVER_API_DOMAIN}/${path}`,
    method: "GET",
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
}

앱스토어 API는 테스트가 가능한 sandbox api를 제공하고 있어서

 

- sandbox용: https://api.storekit-sandbox.itunes.apple.com/

- production용: https://api.storekit.itunes.apple.com/

각 도메인으로 api call을 할 수 있습니다.

 

참고로, sandbox 테스트로 진행한 transaction Id는 16자리 숫자(ex. 2000000123401234)로 이루어져있었습니다.

 

더보기

transactionId vs originalTransactionId

하나의 인앱결제가 발생하면 앱스토어에서는 originalTransactionId를 생성합니다.

일반적인 소비 재화 구매건에 대해서는 transactionId === originalTransactionId이고,

앱 재설치로 구매 항목이 복원되거나 구독상품의 구독을 갱신했을 때 발생하는 결제건에 대해서는 새로운 transactionId가 생성되어 transactionId가 originalTransactionId와 달라집니다.

 

 

TransactionInfo Verify

TransactionInfo API의 response는 JWS 형식의 string입니다.

이 TransactionInfo가 apple-signed JWS인지 확인함으로써 결제건의 유효성을 검증합니다.

 

JWS format TransactionInfo

getTransactionInfo API의 response는 아래와같은 형태입니다.

{
  "signedTransactionInfo": "eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pNIWIL34Jo13LViZAJACzK6Yf0qnvT_BuwOxiMCPE-Y"
}

WWDC2022 세션 캡처

 

JWS format은 {base64encoede_jws_header}.{base64encoded_jws_payload}.{signature} 이고,

transaction info가 apple-signed임을 확인하기 위해서는 header를 파싱한 뒤,

header.x5c의 certification chain을 verify 하면 됩니다.

 

x5c :: X509 Certificate chain

header.x5c는 X509라는 PKI의 certificate chain인데요,

certificate chain의 첫번째 certificate는 root certificate라 하며 root certificate는 self-signed certificate입니다.

root certificate 외의 certificate들은 intermediate certificate라 하며 각 certificate가 다음의 certificate를 sign하는 방식이므로, certificate chain에서는 순서가 가장 중요합니다.

 

transactionInfo header.x5c chain에서는

배열의 마지막 요소가 root certificate이므로, 이 root certificate가 Apple의 CA와 동일한지 확인하고,

각 요소가 다음 요소로 sign된 것이 맞는지 확인하면 됩니다.

(2023년 7월 기준, Apple Root CA - G3 Root를 사용했습니다.)

 

 

구현 코드

import { X509Certificate } from "crypto";

async function verifySignature(signedTransactionInfo: string) {
  // signed info format: {header}.{payload}.{signature}
  // parse and decode header
  const encodedHeader: string = signedTransactionInfo.split(".")[0];
  const header: JWSTransactionHeader = JSON.parse(
    Buffer.from(encodedHeader, "base64").toString()
  );

  // x5c: chained certificated
  const certificates: X509Certificate[] = header.x5c.map((raw: string) =>
    this.generateCertificate(raw)
  );
  const certificateChainLen = certificates.length;

  // reomve certificate indicator
  const appleRootCA = await this.getPrivateKeyFromPemFile(AppleRootCertFile, {
    start: "-----BEGIN CERTIFICATE-----",
    end: "-----END CERTIFICATE-----",
  });
  const rootCertificate = header.x5c[certificateChainLen - 1];

  // The root cert should be the same as the Apple root cert.
  if (rootCertificate ificate !== appleRootCA) {
    throw new Error();
  }

  // If there are more than one certificates in chain, need to verity them one by one.
  if (certificateChainLen > 1) {
    for (let i = 0; i < certificateChainLen - 1; i++) {
      const isValid = certificates[i].verify(certificates[i + 1].publicKey);

      if (!isValid) {
        throw new Error();
      }
    }
  }
}

function generateCertificate(raw: string) {
  const certificate = this.chunkRawCertificate(raw);
  return new X509Certificate(certificate);
}

function chunkRawCertificate(raw: string) {
  const chunkLength = 64;
  const regex = new RegExp(`.{0,${chunkLength}}`, "g");

  const body = raw.match(regex)?.join("\n");
  return `-----BEGIN CERTIFICATE-----\n${body}-----END CERTIFICATE-----`;
}

async function getPrivateKeyFromPemFile(
  fileKey: string,
  indicator?: {
    start: string;
    end: string;
  }
) {
  const file = await s3
    .getObject({
      Bucket: BUCKET,
      Key: fileKey,
    })
    .promise();
  const content = file.Body?.toLocaleString()!;

  return indicator
    ? content // remove indicators and convert to one line string.
        .replace(indicator.start, "")
        .replace(indicator.end, "")
        .replace(/[\n\r]+/g, "")
    : content.replace(/\n$/, ""); // remove only end line break.
}

x5c verify는 node built-in crypto 모듈을 사용하였습니다. (node.js version 15이상 사용 가능)

 

 

 

 

Reference

https://qonversion.io/blog/migrate-iaps-to-app-store-server-api-wwdc22/

 

Integrate and migrate in-app purchases to App Store Server API

In this article, we explore the new capabilities of App Store Server API, how to integrate and migrate in-app subscriptions into API, and how to sign JSON Web Tokens and verify signed transactions. 


qonversion.io

https://stackoverflow.com/questions/69438848/validate-apple-storekit2-in-app-purchase-receipt-jwsrepresentation-in-backend-n

 

Validate Apple StoreKit2 in-app purchase receipt jwsRepresentation in backend (node ideally, but anything works)

How can I validate an in-app purchase JWS Representation from StoreKit2 on my backend in Node? Its easy enough to decode the payload, but I can't find public keys that Apple uses to sign these JWS/...

stackoverflow.com

https://purchasely.com/blog/handle-jws-signature-for-apple-in-app-purchases

 

What is a JWS and how to encode it for Apple In-App Purchases?

Apple has introduced a new way to secure receipt verification from your backend. Read our step-by-step guide to use JSON Web token.

www.purchasely.com

https://stackoverflow.com/questions/69816264/what-fields-do-i-verify-in-a-x509-as-x5c-header-in-a-jws-to-prove-legitimacy-o

 

What fields do I verify in a x509 (as x5c header in a JWS) to prove legitimacy of the Certificate?

I've already posted a similar question here, but I've realized that my issue could have more to do with x509 certificate rather than JWS in general. Here's the thing, I'm pretty new to JWS, and App...

stackoverflow.com