Skip to content

Update library to support App Store Server Notifications v2.10 #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions jws_verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,37 @@ export class SignedDataVerifier {
*/
async verifyAndDecodeNotification(signedPayload: string): Promise<ResponseBodyV2DecodedPayload> {
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
}

/**
Expand Down
58 changes: 58 additions & 0 deletions models/ExternalPurchaseToken.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalPurchaseToken> {
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
}
}
1 change: 1 addition & 0 deletions models/NotificationTypeV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
17 changes: 15 additions & 2 deletions models/ResponseBodyV2DecodedPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
**/
Expand All @@ -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
}


Expand All @@ -72,6 +81,7 @@ export class ResponseBodyV2DecodedPayloadValidator implements Validator<Response
static readonly subtypeValidator = new SubtypeValidator()
static readonly dataValidator = new DataValidator()
static readonly summaryValidator = new SummaryValidator()
static readonly externalPurchaseTokenValidator = new ExternalPurchaseTokenValidator()
validate(obj: any): obj is ResponseBodyV2DecodedPayload {
if ((typeof obj['notificationType'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.notificationTypeValidator.validate(obj['notificationType']))) {
return false
Expand All @@ -94,6 +104,9 @@ export class ResponseBodyV2DecodedPayloadValidator implements Validator<Response
if ((typeof obj['summary'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.summaryValidator.validate(obj['summary']))) {
return false
}
if ((typeof obj['externalPurchaseToken'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.externalPurchaseTokenValidator.validate(obj['externalPurchaseToken']))) {
return false
}
return true
}
}
Expand Down
1 change: 1 addition & 0 deletions models/Subtype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum Subtype {
PRODUCT_NOT_FOR_SALE = "PRODUCT_NOT_FOR_SALE",
SUMMARY = "SUMMARY",
FAILURE = "FAILURE",
UNREPORTED = "UNREPORTED",
}

export class SubtypeValidator extends StringValidator {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"notificationType": "EXTERNAL_PURCHASE_TOKEN",
"subtype": "UNREPORTED",
"notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
"version": "2.0",
"signedDate": 1698148900000,
"externalPurchaseToken": {
"externalPurchaseId": "b2158121-7af9-49d4-9561-1f588205523e",
"tokenCreationDate": 1698148950000,
"appAppleId": 55555,
"bundleId": "com.example"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"notificationType": "EXTERNAL_PURCHASE_TOKEN",
"subtype": "UNREPORTED",
"notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
"version": "2.0",
"signedDate": 1698148900000,
"externalPurchaseToken": {
"externalPurchaseId": "SANDBOX_b2158121-7af9-49d4-9561-1f588205523e",
"tokenCreationDate": 1698148950000,
"appAppleId": 55555,
"bundleId": "com.example"
}
}
52 changes: 52 additions & 0 deletions tests/unit-tests/transaction_decoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('Testing decoding of signed data', () => {
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)
Expand All @@ -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)
Expand All @@ -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)
})
})