Skip to content

Commit 8d7c9b5

Browse files
Update library to support App Store Server Notifications v2.10
https://developer.apple.com/documentation/appstoreservernotifications?changes=latest_minor
1 parent 26ded96 commit 8d7c9b5

8 files changed

+178
-6
lines changed

jws_verification.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,37 @@ export class SignedDataVerifier {
105105
*/
106106
async verifyAndDecodeNotification(signedPayload: string): Promise<ResponseBodyV2DecodedPayload> {
107107
const decodedJWT: ResponseBodyV2DecodedPayload = await this.verifyJWT(signedPayload, this.responseBodyV2DecodedPayloadValidator, this.extractSignedDate);
108-
const appAppleId = decodedJWT.data ? decodedJWT.data.appAppleId : (decodedJWT.summary ? decodedJWT.summary.appAppleId : null)
109-
const bundleId = decodedJWT.data ? decodedJWT.data.bundleId : (decodedJWT.summary ? decodedJWT.summary.bundleId : null)
110-
const environment = decodedJWT.data ? decodedJWT.data.environment : (decodedJWT.summary ? decodedJWT.summary.environment : null)
108+
let appAppleId: number | undefined
109+
let bundleId: string | undefined
110+
let environment: string | undefined
111+
if (decodedJWT.data) {
112+
appAppleId = decodedJWT.data.appAppleId
113+
bundleId = decodedJWT.data.bundleId
114+
environment = decodedJWT.data.environment
115+
} else if (decodedJWT.summary) {
116+
appAppleId = decodedJWT.summary.appAppleId
117+
bundleId = decodedJWT.summary.bundleId
118+
environment = decodedJWT.summary.environment
119+
} else if (decodedJWT.externalPurchaseToken) {
120+
appAppleId = decodedJWT.externalPurchaseToken.appAppleId
121+
bundleId = decodedJWT.externalPurchaseToken.bundleId
122+
if (decodedJWT.externalPurchaseToken.externalPurchaseId && decodedJWT.externalPurchaseToken.externalPurchaseId.startsWith("SANDBOX")) {
123+
environment = Environment.SANDBOX
124+
} else {
125+
environment = Environment.PRODUCTION
126+
}
127+
}
128+
this.verifyNotification(bundleId, appAppleId, environment)
129+
return decodedJWT
130+
}
131+
132+
protected verifyNotification(bundleId?: string, appAppleId?: number, environment?: string) {
111133
if (this.bundleId !== bundleId || (this.environment === Environment.PRODUCTION && this.appAppleId !== appAppleId)) {
112134
throw new VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER)
113135
}
114136
if (this.environment !== environment) {
115137
throw new VerificationException(VerificationStatus.INVALID_ENVIRONMENT)
116138
}
117-
return decodedJWT
118139
}
119140

120141
/**

models/ExternalPurchaseToken.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2024 Apple Inc. Licensed under MIT License.
2+
3+
import { Validator } from "./Validator"
4+
5+
/**
6+
* The payload data that contains an external purchase token.
7+
*
8+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken externalPurchaseToken}
9+
*/
10+
export interface ExternalPurchaseToken {
11+
12+
/**
13+
* The field of an external purchase token that uniquely identifies the token.
14+
*
15+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchaseid externalPurchaseId}
16+
**/
17+
externalPurchaseId?: string
18+
19+
/**
20+
* The field of an external purchase token that contains the UNIX date, in milliseconds, when the system created the token.
21+
*
22+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/tokencreationdate tokenCreationDate}
23+
**/
24+
tokenCreationDate?: number
25+
26+
/**
27+
* The unique identifier of an app in the App Store.
28+
*
29+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/appappleid appAppleId}
30+
**/
31+
appAppleId?: number
32+
33+
/**
34+
* The bundle identifier of an app.
35+
*
36+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/bundleid bundleId}
37+
**/
38+
bundleId?: string
39+
}
40+
41+
42+
export class ExternalPurchaseTokenValidator implements Validator<ExternalPurchaseToken> {
43+
validate(obj: any): obj is ExternalPurchaseToken {
44+
if ((typeof obj['externalPurchaseId'] !== 'undefined') && !(typeof obj['externalPurchaseId'] === "string" || obj['externalPurchaseId'] instanceof String)) {
45+
return false
46+
}
47+
if ((typeof obj['tokenCreationDate'] !== 'undefined') && !(typeof obj['tokenCreationDate'] === "number")) {
48+
return false
49+
}
50+
if ((typeof obj['appAppleId'] !== 'undefined') && !(typeof obj['appAppleId'] === "number")) {
51+
return false
52+
}
53+
if ((typeof obj['bundleId'] !== 'undefined') && !(typeof obj['bundleId'] === "string" || obj['bundleId'] instanceof String)) {
54+
return false
55+
}
56+
return true
57+
}
58+
}

models/NotificationTypeV2.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export enum NotificationTypeV2 {
2525
TEST = "TEST",
2626
RENEWAL_EXTENSION = "RENEWAL_EXTENSION",
2727
REFUND_REVERSED = "REFUND_REVERSED",
28+
EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN",
2829
}
2930

3031
export class NotificationTypeV2Validator extends StringValidator {}

models/ResponseBodyV2DecodedPayload.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Data, DataValidator } from "./Data";
44
import { DecodedSignedData } from "./DecodedSignedData";
5+
import { ExternalPurchaseToken, ExternalPurchaseTokenValidator } from "./ExternalPurchaseToken";
56
import { NotificationTypeV2, NotificationTypeV2Validator } from "./NotificationTypeV2";
67
import { Subtype, SubtypeValidator } from "./Subtype";
78
import { Summary, SummaryValidator } from "./Summary";
@@ -37,7 +38,7 @@ export interface ResponseBodyV2DecodedPayload extends DecodedSignedData {
3738

3839
/**
3940
* The object that contains the app metadata and signed renewal and transaction information.
40-
* The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both.
41+
* The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields.
4142
*
4243
* {@link https://developer.apple.com/documentation/appstoreservernotifications/data data}
4344
**/
@@ -59,11 +60,19 @@ export interface ResponseBodyV2DecodedPayload extends DecodedSignedData {
5960

6061
/**
6162
* The summary data that appears when the App Store server completes your request to extend a subscription renewal date for eligible subscribers.
62-
* The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both.
63+
* The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields.
6364
*
6465
* {@link https://developer.apple.com/documentation/appstoreservernotifications/summary summary}
6566
**/
6667
summary?: Summary
68+
69+
/**
70+
* This field appears when the notificationType is EXTERNAL_PURCHASE_TOKEN.
71+
* The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields.
72+
*
73+
* {@link https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken externalPurchaseToken}
74+
**/
75+
externalPurchaseToken?: ExternalPurchaseToken
6776
}
6877

6978

@@ -72,6 +81,7 @@ export class ResponseBodyV2DecodedPayloadValidator implements Validator<Response
7281
static readonly subtypeValidator = new SubtypeValidator()
7382
static readonly dataValidator = new DataValidator()
7483
static readonly summaryValidator = new SummaryValidator()
84+
static readonly externalPurchaseTokenValidator = new ExternalPurchaseTokenValidator()
7585
validate(obj: any): obj is ResponseBodyV2DecodedPayload {
7686
if ((typeof obj['notificationType'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.notificationTypeValidator.validate(obj['notificationType']))) {
7787
return false
@@ -94,6 +104,9 @@ export class ResponseBodyV2DecodedPayloadValidator implements Validator<Response
94104
if ((typeof obj['summary'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.summaryValidator.validate(obj['summary']))) {
95105
return false
96106
}
107+
if ((typeof obj['externalPurchaseToken'] !== 'undefined') && !(ResponseBodyV2DecodedPayloadValidator.externalPurchaseTokenValidator.validate(obj['externalPurchaseToken']))) {
108+
return false
109+
}
97110
return true
98111
}
99112
}

models/Subtype.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export enum Subtype {
2424
PRODUCT_NOT_FOR_SALE = "PRODUCT_NOT_FOR_SALE",
2525
SUMMARY = "SUMMARY",
2626
FAILURE = "FAILURE",
27+
UNREPORTED = "UNREPORTED",
2728
}
2829

2930
export class SubtypeValidator extends StringValidator {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"notificationType": "EXTERNAL_PURCHASE_TOKEN",
3+
"subtype": "UNREPORTED",
4+
"notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
5+
"version": "2.0",
6+
"signedDate": 1698148900000,
7+
"externalPurchaseToken": {
8+
"externalPurchaseId": "b2158121-7af9-49d4-9561-1f588205523e",
9+
"tokenCreationDate": 1698148950000,
10+
"appAppleId": 55555,
11+
"bundleId": "com.example"
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"notificationType": "EXTERNAL_PURCHASE_TOKEN",
3+
"subtype": "UNREPORTED",
4+
"notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
5+
"version": "2.0",
6+
"signedDate": 1698148900000,
7+
"externalPurchaseToken": {
8+
"externalPurchaseId": "SANDBOX_b2158121-7af9-49d4-9561-1f588205523e",
9+
"tokenCreationDate": 1698148950000,
10+
"appAppleId": 55555,
11+
"bundleId": "com.example"
12+
}
13+
}

tests/unit-tests/transaction_decoding.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('Testing decoding of signed data', () => {
9494
expect(1698148900000).toBe(notification.signedDate)
9595
expect(notification.data).toBeTruthy()
9696
expect(notification.summary).toBeFalsy()
97+
expect(notification.externalPurchaseToken).toBeFalsy()
9798
expect(Environment.LOCAL_TESTING).toBe(notification.data!.environment)
9899
expect(41234).toBe(notification.data!.appAppleId)
99100
expect("com.example").toBe(notification.data!.bundleId)
@@ -114,6 +115,7 @@ describe('Testing decoding of signed data', () => {
114115
expect(1698148900000).toBe(notification.signedDate)
115116
expect(notification.data).toBeFalsy();
116117
expect(notification.summary).toBeTruthy();
118+
expect(notification.externalPurchaseToken).toBeFalsy()
117119
expect(Environment.LOCAL_TESTING).toBe(notification.summary!.environment)
118120
expect(41234).toBe(notification.summary!.appAppleId)
119121
expect("com.example").toBe(notification.summary!.bundleId)
@@ -123,4 +125,54 @@ describe('Testing decoding of signed data', () => {
123125
expect(5).toBe(notification.summary!.succeededCount)
124126
expect(2).toBe(notification.summary!.failedCount)
125127
})
128+
129+
it('should decode a signed external purchase token notification', async () => {
130+
const signedNotification = createSignedDataFromJson("tests/resources/models/signedExternalPurchaseTokenNotification.json")
131+
132+
const verifier = await getDefaultSignedPayloadVerifier();
133+
(verifier as any).verifyNotification = function(bundleId?: string, appAppleId?: number, environment?: string) {
134+
expect(bundleId).toBe("com.example")
135+
expect(appAppleId).toBe(55555)
136+
expect(environment).toBe(Environment.PRODUCTION)
137+
}
138+
const notification = await verifier.verifyAndDecodeNotification(signedNotification)
139+
140+
expect(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN).toBe(notification.notificationType)
141+
expect(Subtype.UNREPORTED).toBe(notification.subtype)
142+
expect("002e14d5-51f5-4503-b5a8-c3a1af68eb20").toBe(notification.notificationUUID)
143+
expect("2.0").toBe(notification.version)
144+
expect(1698148900000).toBe(notification.signedDate)
145+
expect(notification.data).toBeFalsy();
146+
expect(notification.summary).toBeFalsy();
147+
expect(notification.externalPurchaseToken).toBeTruthy()
148+
expect("b2158121-7af9-49d4-9561-1f588205523e").toBe(notification.externalPurchaseToken!.externalPurchaseId)
149+
expect(1698148950000).toBe(notification.externalPurchaseToken!.tokenCreationDate)
150+
expect(55555).toBe(notification.externalPurchaseToken!.appAppleId)
151+
expect("com.example").toBe(notification.externalPurchaseToken!.bundleId)
152+
})
153+
154+
it('should decode a signed sandbox external purchase token notification', async () => {
155+
const signedNotification = createSignedDataFromJson("tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json")
156+
157+
const verifier = await getDefaultSignedPayloadVerifier();
158+
(verifier as any).verifyNotification = function(bundleId?: string, appAppleId?: number, environment?: string) {
159+
expect(bundleId).toBe("com.example")
160+
expect(appAppleId).toBe(55555)
161+
expect(environment).toBe(Environment.SANDBOX)
162+
}
163+
const notification = await verifier.verifyAndDecodeNotification(signedNotification)
164+
165+
expect(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN).toBe(notification.notificationType)
166+
expect(Subtype.UNREPORTED).toBe(notification.subtype)
167+
expect("002e14d5-51f5-4503-b5a8-c3a1af68eb20").toBe(notification.notificationUUID)
168+
expect("2.0").toBe(notification.version)
169+
expect(1698148900000).toBe(notification.signedDate)
170+
expect(notification.data).toBeFalsy();
171+
expect(notification.summary).toBeFalsy();
172+
expect(notification.externalPurchaseToken).toBeTruthy()
173+
expect("SANDBOX_b2158121-7af9-49d4-9561-1f588205523e").toBe(notification.externalPurchaseToken!.externalPurchaseId)
174+
expect(1698148950000).toBe(notification.externalPurchaseToken!.tokenCreationDate)
175+
expect(55555).toBe(notification.externalPurchaseToken!.appAppleId)
176+
expect("com.example").toBe(notification.externalPurchaseToken!.bundleId)
177+
})
126178
})

0 commit comments

Comments
 (0)