diff --git a/README.md b/README.md index 05a9712..e3b01a6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The Node.js server library for the [App Store Server API](https://developer.appl ## Installation -#### Requirements +### Requirements - Node 16+ @@ -125,12 +125,12 @@ const encodedKey = readFile(filePath) // Specific implementation may vary const productId = "" const subscriptionOfferId = "" -const applicationUsername = "" +const appAccountToken = "" const nonce = "" const timestamp = Date.now() const signatureCreator = new PromotionalOfferSignatureCreator(encodedKey, keyId, bundleId) -const signature = signatureCreator.createSignature(productId, subscriptionOfferId, applicationUsername, nonce, timestamp) +const signature = signatureCreator.createSignature(productId, subscriptionOfferId, appAccountToken, nonce, timestamp) console.log(signature) ``` diff --git a/index.ts b/index.ts index 19d6764..1582b79 100644 --- a/index.ts +++ b/index.ts @@ -55,6 +55,7 @@ export { OrderLookupStatus } from './models/OrderLookupStatus' export { Platform } from './models/Platform' export { PlayTime } from './models/PlayTime' export { PriceIncreaseStatus } from './models/PriceIncreaseStatus' +export { PurchasePlatform } from './models/PurchasePlatform' export { RefundHistoryResponse } from './models/RefundHistoryResponse' export { ResponseBodyV2 } from './models/ResponseBodyV2' export { ResponseBodyV2DecodedPayload } from './models/ResponseBodyV2DecodedPayload' @@ -71,6 +72,7 @@ export { TransactionReason } from './models/TransactionReason' export { Type } from './models/Type' export { UserStatus } from './models/UserStatus' export { PromotionalOfferSignatureCreator } from './promotional_offer' +export { PromotionalOfferV2SignatureCreator, AdvancedCommerceInAppSignatureCreator, AdvancedCommerceInAppRequest, IntroductoryOfferEligibilitySignatureCreator } from './jws_signature_creator' export { DecodedSignedData } from './models/DecodedSignedData' export { AppTransaction } from './models/AppTransaction' @@ -685,6 +687,13 @@ export enum APIError { */ INVALID_TRANSACTION_TYPE_NOT_SUPPORTED = 4000047, + /** + * An error that indicates the endpoint doesn't support an app transaction ID. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror AppTransactionIdNotSupportedError} + */ + APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048, + /** * An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. * diff --git a/jws_signature_creator.ts b/jws_signature_creator.ts new file mode 100644 index 0000000..1136b78 --- /dev/null +++ b/jws_signature_creator.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import jsonwebtoken = require('jsonwebtoken'); +import { randomUUID } from 'crypto'; + +class BaseSignatureCreator { + private audience: string + private signingKey: string + private keyId: string + private issuerId: string + private bundleId: string + + public constructor(audience: string, signingKey: string, keyId: string, issuerId: string, bundleId: string) { + this.audience = audience + this.issuerId = issuerId + this.keyId = keyId + this.bundleId = bundleId + this.signingKey = signingKey + } + + protected internalCreateSignature(featureSpecificClaims: { [key: string]: any }) { + var claims = featureSpecificClaims + + claims['bid'] = this.bundleId + claims['nonce'] = randomUUID() + + return jsonwebtoken.sign(claims, this.signingKey, { algorithm: 'ES256', keyid: this.keyId, issuer: this.issuerId, audience: this.audience}) + } +} + +export class PromotionalOfferV2SignatureCreator extends BaseSignatureCreator { + /** + * Create a PromotionalOfferV2SignatureCreator + * + * @param signingKey Your private key downloaded from App Store Connect + * @param keyId Your private key ID from App Store Connect + * @param issuerId Your issuer ID from the Keys page in App Store Connect + * @param bundleId Your app's bundle ID + */ + public constructor(signingKey: string, keyId: string, issuerId: string, bundleId: string) { + super('promotional-offer', signingKey, keyId, issuerId, bundleId) + } + + /** + * Create a promotional offer V2 signature. + * + * @param productId The unique identifier of the product + * @param offerIdentifier The promotional offer identifier that you set up in App Store Connect + * @param transactionId The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended. + * @return The signed JWS. + * {@link https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests Generating JWS to sign App Store requests} + */ + public createSignature(productId: string, offerIdentifier: string, transactionId: string | undefined = undefined) { + let featureSpecificClaims: { [key: string]: any } = {} + featureSpecificClaims['productId'] = productId + featureSpecificClaims['offerIdentifier'] = offerIdentifier + if (transactionId != null) { + featureSpecificClaims['transactionId'] = transactionId + } + return super.internalCreateSignature(featureSpecificClaims) + } +} + +export class IntroductoryOfferEligibilitySignatureCreator extends BaseSignatureCreator { + /** + * Create a IntroductoryOfferEligibilitySignatureCreator + * + * @param signingKey Your private key downloaded from App Store Connect + * @param keyId Your private key ID from App Store Connect + * @param issuerId Your issuer ID from the Keys page in App Store Connect + * @param bundleId Your app's bundle ID + */ + public constructor(signingKey: string, keyId: string, issuerId: string, bundleId: string) { + super('introductory-offer-eligibility', signingKey, keyId, issuerId, bundleId) + } + + /** + * Create an introductory offer eligibility signature. + * + * @param productId The unique identifier of the product + * @param allowIntroductoryOffer A boolean value that determines whether the customer is eligible for an introductory offer + * @param transactionId The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. + * @return The signed JWS. + * {@link https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests Generating JWS to sign App Store requests} + */ + public createSignature(productId: string, allowIntroductoryOffer: boolean, transactionId: string) { + let featureSpecificClaims: { [key: string]: any } = {} + featureSpecificClaims['productId'] = productId + featureSpecificClaims['allowIntroductoryOffer'] = allowIntroductoryOffer + featureSpecificClaims['transactionId'] = transactionId + return super.internalCreateSignature(featureSpecificClaims) + } +} + +export interface AdvancedCommerceInAppRequest {} + +export class AdvancedCommerceInAppSignatureCreator extends BaseSignatureCreator { + /** + * Create a AdvancedCommerceInAppSignatureCreator + * + * @param signingKey Your private key downloaded from App Store Connect + * @param keyId Your private key ID from App Store Connect + * @param issuerId Your issuer ID from the Keys page in App Store Connect + * @param bundleId Your app's bundle ID + */ + public constructor(signingKey: string, keyId: string, issuerId: string, bundleId: string) { + super('advanced-commerce-api', signingKey, keyId, issuerId, bundleId) + } + + /** + * Create an Advanced Commerce in-app signed request. + * + * @param AdvancedCommerceInAppRequest The request to be signed. + * @return The signed JWS. + * {@link https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests Generating JWS to sign App Store requests} + */ + public createSignature(AdvancedCommerceInAppRequest: AdvancedCommerceInAppRequest) { + let jsonRequest = JSON.stringify(AdvancedCommerceInAppRequest) + + let base64Request = Buffer.from(jsonRequest, 'utf-8').toString('base64') + + let featureSpecificClaims: { [key: string]: string } = {} + featureSpecificClaims['request'] = base64Request + return super.internalCreateSignature(featureSpecificClaims) + } +} \ No newline at end of file diff --git a/models/AppTransaction.ts b/models/AppTransaction.ts index ade674e..0405c66 100644 --- a/models/AppTransaction.ts +++ b/models/AppTransaction.ts @@ -1,6 +1,7 @@ // Copyright (c) 2023 Apple Inc. Licensed under MIT License. import { Environment, EnvironmentValidator } from "./Environment" +import { PurchasePlatform, PurchasePlatformValidator } from "./PurchasePlatform" import { Validator } from "./Validator" /** @@ -86,10 +87,25 @@ export interface AppTransaction { * {@link https://developer.apple.com/documentation/storekit/apptransaction/4013175-preorderdate preorderDate} */ preorderDate?: number + + /** + * The unique identifier of the app download transaction. + * + * {@link https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid appTransactionId} + */ + appTransactionId?: string + + /** + * The platform on which the customer originally purchased the app. + * + * {@link https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz originalPlatform} + */ + originalPlatform?: PurchasePlatform | string } export class AppTransactionValidator implements Validator { static readonly environmentValidator = new EnvironmentValidator() + static readonly originalPlatformValidator = new PurchasePlatformValidator() validate(obj: any): obj is AppTransaction { if ((typeof obj['appAppleId'] !== 'undefined') && !(typeof obj['appAppleId'] === "number")) { return false @@ -124,6 +140,12 @@ export class AppTransactionValidator implements Validator { if ((typeof obj['environment'] !== 'undefined') && !(AppTransactionValidator.environmentValidator.validate(obj['environment']))) { return false } + if ((typeof obj['appTransactionId'] !== 'undefined') && !(typeof obj['appTransactionId'] === "string" || obj['appTransactionId'] instanceof String)) { + return false + } + if ((typeof obj['originalPlatform'] !== 'undefined') && !(AppTransactionValidator.originalPlatformValidator.validate(obj['originalPlatform']))) { + return false + } return true } } \ No newline at end of file diff --git a/models/JWSRenewalInfoDecodedPayload.ts b/models/JWSRenewalInfoDecodedPayload.ts index 0a2851c..7039614 100644 --- a/models/JWSRenewalInfoDecodedPayload.ts +++ b/models/JWSRenewalInfoDecodedPayload.ts @@ -140,7 +140,28 @@ export interface JWSRenewalInfoDecodedPayload extends DecodedSignedData { * * {@link https://developer.apple.com/documentation/appstoreserverapi/eligiblewinbackofferids eligibleWinBackOfferIds} **/ - eligibleWinBackOfferIds?: string[]; + eligibleWinBackOfferIds?: string[] + + /** + * The UUID that an app optionally generates to map a customer’s in-app purchase with its resulting App Store transaction. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken appAccountToken} + **/ + appAccountToken?: string + + /** + * The unique identifier of the app download transaction. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/appTransactionId appTransactionId} + **/ + appTransactionId?: string + + /** + * The duration of the offer. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/offerPeriod offerPeriod} + **/ + offerPeriod?: string } @@ -213,6 +234,15 @@ export class JWSRenewalInfoDecodedPayloadValidator implements Validator { + it('should create a promotional offer signature', async () => { + const signatureCreator = new PromotionalOfferV2SignatureCreator(readFile('tests/resources/certs/testSigningKey.p8'), "keyId", "issuerId", "bundleId"); + const signature = signatureCreator.createSignature('productId', 'offerIdentifier', 'transactionId') + expect(signature).toBeTruthy() + let header = JSON.parse(Buffer.from(signature.split('.')[0], 'base64url').toString()) + let payload = JSON.parse(Buffer.from(signature.split('.')[1], 'base64url').toString()) + + // Header + expect('JWT').toBe(header['typ']) + expect('ES256').toBe(header['alg']) + expect('keyId').toBe(header['kid']) + // Payload + expect('issuerId').toBe(payload['iss']) + expect(payload['iat']).toBeTruthy() + expect(payload['exp']).toBeUndefined() + expect('promotional-offer').toBe(payload['aud']) + expect('bundleId').toBe(payload['bid']) + expect(payload['nonce']).toBeTruthy() + expect('productId').toBe(payload['productId']) + expect('offerIdentifier').toBe(payload['offerIdentifier']) + expect('transactionId').toBe(payload['transactionId']) + }) + + it('should create a promotional offer signature without a transaction id', async () => { + const signatureCreator = new PromotionalOfferV2SignatureCreator(readFile('tests/resources/certs/testSigningKey.p8'), "keyId", "issuerId", "bundleId"); + const signature = signatureCreator.createSignature('productId', 'offerIdentifier') + let payload = JSON.parse(Buffer.from(signature.split('.')[1], 'base64url').toString()) + expect(payload['transactionId']).toBeUndefined() + }) + + it('should create a introductory eligibility offer signature', async () => { + const signatureCreator = new IntroductoryOfferEligibilitySignatureCreator(readFile('tests/resources/certs/testSigningKey.p8'), "keyId", "issuerId", "bundleId"); + const signature = signatureCreator.createSignature('productId', true, 'transactionId') + expect(signature).toBeTruthy() + let header = JSON.parse(Buffer.from(signature.split('.')[0], 'base64url').toString()) + let payload = JSON.parse(Buffer.from(signature.split('.')[1], 'base64url').toString()) + + // Header + expect('JWT').toBe(header['typ']) + expect('ES256').toBe(header['alg']) + expect('keyId').toBe(header['kid']) + // Payload + expect('issuerId').toBe(payload['iss']) + expect(payload['iat']).toBeTruthy() + expect(payload['exp']).toBeUndefined() + expect('introductory-offer-eligibility').toBe(payload['aud']) + expect('bundleId').toBe(payload['bid']) + expect(payload['nonce']).toBeTruthy() + expect('productId').toBe(payload['productId']) + expect(true).toBe(payload['allowIntroductoryOffer']) + expect('transactionId').toBe(payload['transactionId']) + }) + + it('should create an Advanced Commerce in app signature', async () => { + const signatureCreator = new AdvancedCommerceInAppSignatureCreator(readFile('tests/resources/certs/testSigningKey.p8'), "keyId", "issuerId", "bundleId"); + let request: TestInAppRequest = { + testValue: "testValue" + } + const signature = signatureCreator.createSignature(request) + expect(signature).toBeTruthy() + let header = JSON.parse(Buffer.from(signature.split('.')[0], 'base64url').toString()) + let payload = JSON.parse(Buffer.from(signature.split('.')[1], 'base64url').toString()) + + // Header + expect('JWT').toBe(header['typ']) + expect('ES256').toBe(header['alg']) + expect('keyId').toBe(header['kid']) + // Payload + expect('issuerId').toBe(payload['iss']) + expect(payload['iat']).toBeTruthy() + expect(payload['exp']).toBeUndefined() + expect('advanced-commerce-api').toBe(payload['aud']) + expect('bundleId').toBe(payload['bid']) + expect(payload['nonce']).toBeTruthy() + let parsedRequestJson = Buffer.from(payload['request'], 'base64').toString('utf-8') + let parsedRequest = JSON.parse(parsedRequestJson) + expect(parsedRequest['testValue']).toBe('testValue') + }) +}) \ No newline at end of file diff --git a/tests/unit-tests/promotional_offer_signature_creator.test.ts b/tests/unit-tests/promotional_offer_signature_creator.test.ts index f577ab5..87d9a59 100644 --- a/tests/unit-tests/promotional_offer_signature_creator.test.ts +++ b/tests/unit-tests/promotional_offer_signature_creator.test.ts @@ -7,7 +7,7 @@ import { readFile } from "../util" describe('Promotional Offer Signature Creation Test', () => { it('should create a non-null signature', async () => { const signatureCreator = new PromotionalOfferSignatureCreator(readFile('tests/resources/certs/testSigningKey.p8'), "keyId", "bundleId"); - const signature = signatureCreator.createSignature('productId', 'offerId', 'applicationUsername', "20fba8a0-2b80-4a7d-a17f-85c1854727f8", 1698148900000) + const signature = signatureCreator.createSignature('productId', 'offerId', 'appAccountToken', "20fba8a0-2b80-4a7d-a17f-85c1854727f8", 1698148900000) expect(signature).toBeTruthy() }) }) \ No newline at end of file diff --git a/tests/unit-tests/transaction_decoding.test.ts b/tests/unit-tests/transaction_decoding.test.ts index 078c33d..91e0c99 100644 --- a/tests/unit-tests/transaction_decoding.test.ts +++ b/tests/unit-tests/transaction_decoding.test.ts @@ -6,6 +6,7 @@ import { ExpirationIntent } from "../../models/ExpirationIntent"; import { NotificationTypeV2 } from "../../models/NotificationTypeV2"; import { OfferType } from "../../models/OfferType"; import { PriceIncreaseStatus } from "../../models/PriceIncreaseStatus"; +import { PurchasePlatform } from "../../models/PurchasePlatform"; import { Status } from "../../models/Status"; import { Subtype } from "../../models/Subtype"; import { createSignedDataFromJson, getDefaultSignedPayloadVerifier } from "../util" @@ -34,6 +35,8 @@ describe('Testing decoding of signed data', () => { expect("device_verification_value").toBe(appTransaction.deviceVerification) expect("48ccfa42-7431-4f22-9908-7e88983e105a").toBe(appTransaction.deviceVerificationNonce) expect(1698148700000).toBe(appTransaction.preorderDate) + expect("71134").toBe(appTransaction.appTransactionId) + expect(PurchasePlatform.IOS).toBe(appTransaction.originalPlatform) }) it('should decode a renewal info', async () => { const signedRenewalInfo = createSignedDataFromJson("tests/resources/models/signedRenewalInfo.json") @@ -58,6 +61,9 @@ describe('Testing decoding of signed data', () => { expect("USD").toBe(renewalInfo.currency) expect(OfferDiscountType.PAY_AS_YOU_GO).toBe(renewalInfo.offerDiscountType) expect(["eligible1", "eligible2"]).toStrictEqual(renewalInfo.eligibleWinBackOfferIds) + expect("71134").toBe(renewalInfo.appTransactionId) + expect("P1Y").toBe(renewalInfo.offerPeriod) + expect("7e3fb20b-4cdb-47cc-936d-99d65f608138").toBe(renewalInfo.appAccountToken) }) it('should decode a transaction info', async () => { const signedTransaction = createSignedDataFromJson("tests/resources/models/signedTransaction.json") @@ -90,6 +96,8 @@ describe('Testing decoding of signed data', () => { expect(10990).toBe(transaction.price) expect("USD").toBe(transaction.currency) expect(OfferDiscountType.PAY_AS_YOU_GO).toBe(transaction.offerDiscountType) + expect("71134").toBe(transaction.appTransactionId) + expect("P1Y").toBe(transaction.offerPeriod) }) it('should decode a signed notification', async () => { const signedNotification = createSignedDataFromJson("tests/resources/models/signedNotification.json")