diff --git a/jws_verification.ts b/jws_verification.ts index 02b2da7..5515492 100644 --- a/jws_verification.ts +++ b/jws_verification.ts @@ -105,16 +105,37 @@ export class SignedDataVerifier { */ async verifyAndDecodeNotification(signedPayload: string): Promise { const decodedJWT: ResponseBodyV2DecodedPayload = await this.verifyJWT(signedPayload, this.responseBodyV2DecodedPayloadValidator, this.extractSignedDate); - const appAppleId = decodedJWT.data ? decodedJWT.data.appAppleId : (decodedJWT.summary ? decodedJWT.summary.appAppleId : null) - const bundleId = decodedJWT.data ? decodedJWT.data.bundleId : (decodedJWT.summary ? decodedJWT.summary.bundleId : null) - const environment = decodedJWT.data ? decodedJWT.data.environment : (decodedJWT.summary ? decodedJWT.summary.environment : null) + let appAppleId: number | undefined + let bundleId: string | undefined + let environment: string | undefined + if (decodedJWT.data) { + appAppleId = decodedJWT.data.appAppleId + bundleId = decodedJWT.data.bundleId + environment = decodedJWT.data.environment + } else if (decodedJWT.summary) { + appAppleId = decodedJWT.summary.appAppleId + bundleId = decodedJWT.summary.bundleId + environment = decodedJWT.summary.environment + } else if (decodedJWT.externalPurchaseToken) { + appAppleId = decodedJWT.externalPurchaseToken.appAppleId + bundleId = decodedJWT.externalPurchaseToken.bundleId + if (decodedJWT.externalPurchaseToken.externalPurchaseId && decodedJWT.externalPurchaseToken.externalPurchaseId.startsWith("SANDBOX")) { + environment = Environment.SANDBOX + } else { + environment = Environment.PRODUCTION + } + } + this.verifyNotification(bundleId, appAppleId, environment) + return decodedJWT + } + + protected verifyNotification(bundleId?: string, appAppleId?: number, environment?: string) { if (this.bundleId !== bundleId || (this.environment === Environment.PRODUCTION && this.appAppleId !== appAppleId)) { throw new VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) } if (this.environment !== environment) { throw new VerificationException(VerificationStatus.INVALID_ENVIRONMENT) } - return decodedJWT } /** diff --git a/models/ExternalPurchaseToken.ts b/models/ExternalPurchaseToken.ts new file mode 100644 index 0000000..bb6852f --- /dev/null +++ b/models/ExternalPurchaseToken.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +import { Validator } from "./Validator" + +/** + * The payload data that contains an external purchase token. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken externalPurchaseToken} + */ +export interface ExternalPurchaseToken { + + /** + * The field of an external purchase token that uniquely identifies the token. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchaseid externalPurchaseId} + **/ + externalPurchaseId?: string + + /** + * The field of an external purchase token that contains the UNIX date, in milliseconds, when the system created the token. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/tokencreationdate tokenCreationDate} + **/ + tokenCreationDate?: number + + /** + * The unique identifier of an app in the App Store. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/appappleid appAppleId} + **/ + appAppleId?: number + + /** + * The bundle identifier of an app. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/bundleid bundleId} + **/ + bundleId?: string +} + + +export class ExternalPurchaseTokenValidator implements Validator { + validate(obj: any): obj is ExternalPurchaseToken { + if ((typeof obj['externalPurchaseId'] !== 'undefined') && !(typeof obj['externalPurchaseId'] === "string" || obj['externalPurchaseId'] instanceof String)) { + return false + } + if ((typeof obj['tokenCreationDate'] !== 'undefined') && !(typeof obj['tokenCreationDate'] === "number")) { + return false + } + if ((typeof obj['appAppleId'] !== 'undefined') && !(typeof obj['appAppleId'] === "number")) { + return false + } + if ((typeof obj['bundleId'] !== 'undefined') && !(typeof obj['bundleId'] === "string" || obj['bundleId'] instanceof String)) { + return false + } + return true + } +} diff --git a/models/NotificationTypeV2.ts b/models/NotificationTypeV2.ts index 1f0ea5e..f87760a 100644 --- a/models/NotificationTypeV2.ts +++ b/models/NotificationTypeV2.ts @@ -25,6 +25,7 @@ export enum NotificationTypeV2 { TEST = "TEST", RENEWAL_EXTENSION = "RENEWAL_EXTENSION", REFUND_REVERSED = "REFUND_REVERSED", + EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN", } export class NotificationTypeV2Validator extends StringValidator {} \ No newline at end of file diff --git a/models/ResponseBodyV2DecodedPayload.ts b/models/ResponseBodyV2DecodedPayload.ts index 4a65bdd..4fa3437 100644 --- a/models/ResponseBodyV2DecodedPayload.ts +++ b/models/ResponseBodyV2DecodedPayload.ts @@ -2,6 +2,7 @@ import { Data, DataValidator } from "./Data"; import { DecodedSignedData } from "./DecodedSignedData"; +import { ExternalPurchaseToken, ExternalPurchaseTokenValidator } from "./ExternalPurchaseToken"; import { NotificationTypeV2, NotificationTypeV2Validator } from "./NotificationTypeV2"; import { Subtype, SubtypeValidator } from "./Subtype"; import { Summary, SummaryValidator } from "./Summary"; @@ -37,7 +38,7 @@ export interface ResponseBodyV2DecodedPayload extends DecodedSignedData { /** * The object that contains the app metadata and signed renewal and transaction information. - * The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + * The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. * * {@link https://developer.apple.com/documentation/appstoreservernotifications/data data} **/ @@ -59,11 +60,19 @@ export interface ResponseBodyV2DecodedPayload extends DecodedSignedData { /** * The summary data that appears when the App Store server completes your request to extend a subscription renewal date for eligible subscribers. - * The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + * The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. * * {@link https://developer.apple.com/documentation/appstoreservernotifications/summary summary} **/ summary?: Summary + + /** + * This field appears when the notificationType is EXTERNAL_PURCHASE_TOKEN. + * The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. + * + * {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken externalPurchaseToken} + **/ + externalPurchaseToken?: ExternalPurchaseToken } @@ -72,6 +81,7 @@ export class ResponseBodyV2DecodedPayloadValidator implements Validator { expect(1698148900000).toBe(notification.signedDate) expect(notification.data).toBeTruthy() expect(notification.summary).toBeFalsy() + expect(notification.externalPurchaseToken).toBeFalsy() expect(Environment.LOCAL_TESTING).toBe(notification.data!.environment) expect(41234).toBe(notification.data!.appAppleId) expect("com.example").toBe(notification.data!.bundleId) @@ -114,6 +115,7 @@ describe('Testing decoding of signed data', () => { expect(1698148900000).toBe(notification.signedDate) expect(notification.data).toBeFalsy(); expect(notification.summary).toBeTruthy(); + expect(notification.externalPurchaseToken).toBeFalsy() expect(Environment.LOCAL_TESTING).toBe(notification.summary!.environment) expect(41234).toBe(notification.summary!.appAppleId) expect("com.example").toBe(notification.summary!.bundleId) @@ -123,4 +125,54 @@ describe('Testing decoding of signed data', () => { expect(5).toBe(notification.summary!.succeededCount) expect(2).toBe(notification.summary!.failedCount) }) + + it('should decode a signed external purchase token notification', async () => { + const signedNotification = createSignedDataFromJson("tests/resources/models/signedExternalPurchaseTokenNotification.json") + + const verifier = await getDefaultSignedPayloadVerifier(); + (verifier as any).verifyNotification = function(bundleId?: string, appAppleId?: number, environment?: string) { + expect(bundleId).toBe("com.example") + expect(appAppleId).toBe(55555) + expect(environment).toBe(Environment.PRODUCTION) + } + const notification = await verifier.verifyAndDecodeNotification(signedNotification) + + expect(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN).toBe(notification.notificationType) + expect(Subtype.UNREPORTED).toBe(notification.subtype) + expect("002e14d5-51f5-4503-b5a8-c3a1af68eb20").toBe(notification.notificationUUID) + expect("2.0").toBe(notification.version) + expect(1698148900000).toBe(notification.signedDate) + expect(notification.data).toBeFalsy(); + expect(notification.summary).toBeFalsy(); + expect(notification.externalPurchaseToken).toBeTruthy() + expect("b2158121-7af9-49d4-9561-1f588205523e").toBe(notification.externalPurchaseToken!.externalPurchaseId) + expect(1698148950000).toBe(notification.externalPurchaseToken!.tokenCreationDate) + expect(55555).toBe(notification.externalPurchaseToken!.appAppleId) + expect("com.example").toBe(notification.externalPurchaseToken!.bundleId) + }) + + it('should decode a signed sandbox external purchase token notification', async () => { + const signedNotification = createSignedDataFromJson("tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json") + + const verifier = await getDefaultSignedPayloadVerifier(); + (verifier as any).verifyNotification = function(bundleId?: string, appAppleId?: number, environment?: string) { + expect(bundleId).toBe("com.example") + expect(appAppleId).toBe(55555) + expect(environment).toBe(Environment.SANDBOX) + } + const notification = await verifier.verifyAndDecodeNotification(signedNotification) + + expect(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN).toBe(notification.notificationType) + expect(Subtype.UNREPORTED).toBe(notification.subtype) + expect("002e14d5-51f5-4503-b5a8-c3a1af68eb20").toBe(notification.notificationUUID) + expect("2.0").toBe(notification.version) + expect(1698148900000).toBe(notification.signedDate) + expect(notification.data).toBeFalsy(); + expect(notification.summary).toBeFalsy(); + expect(notification.externalPurchaseToken).toBeTruthy() + expect("SANDBOX_b2158121-7af9-49d4-9561-1f588205523e").toBe(notification.externalPurchaseToken!.externalPurchaseId) + expect(1698148950000).toBe(notification.externalPurchaseToken!.tokenCreationDate) + expect(55555).toBe(notification.externalPurchaseToken!.appAppleId) + expect("com.example").toBe(notification.externalPurchaseToken!.bundleId) + }) }) \ No newline at end of file