From f2e2378e42c7ba3286abc5e10c92187a8aab9b3e Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Thu, 25 Sep 2025 15:35:57 -0700 Subject: [PATCH 1/3] Implement decoding of RealtimeRequestBody Add serializer for RealtimeResponseBody Remove response model files Revert changes --- .../models/DecodedRealtimeRequestBody.py | 69 +++++++++++++++++++ .../models/RealtimeRequestBody.py | 20 ++++++ appstoreserverlibrary/signed_data_verifier.py | 24 +++++++ .../mock_signed_data/retentionRequest | 1 + tests/test_payload_verification.py | 20 ++++++ 5 files changed, 134 insertions(+) create mode 100644 appstoreserverlibrary/models/DecodedRealtimeRequestBody.py create mode 100644 appstoreserverlibrary/models/RealtimeRequestBody.py create mode 100644 tests/resources/mock_signed_data/retentionRequest diff --git a/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py new file mode 100644 index 00000000..617d4eaa --- /dev/null +++ b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py @@ -0,0 +1,69 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr +from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware + +@define +class DecodedRealtimeRequestBody(AttrsRawValueAware): + """ + The decoded request body the App Store sends to your server to request a real-time retention message. + + https://developer.apple.com/documentation/retentionmessaging/decodedrealtimerequestbody + """ + + originalTransactionId: Optional[str] = attr.ib(default=None) + """ + The original transaction identifier of the customer's subscription. + + https://developer.apple.com/documentation/retentionmessaging/originaltransactionid + """ + + appAppleId: Optional[int] = attr.ib(default=None) + """ + The unique identifier of the app in the App Store. + + https://developer.apple.com/documentation/retentionmessaging/appappleid + """ + + productId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the auto-renewable subscription. + + https://developer.apple.com/documentation/retentionmessaging/productid + """ + + locale: Optional[str] = attr.ib(default=None) + """ + The device's locale. + + https://developer.apple.com/documentation/retentionmessaging/locale + """ + + requestIdentifier: Optional[str] = attr.ib(default=None) + """ + A UUID the App Store server creates to uniquely identify each request. + + https://developer.apple.com/documentation/retentionmessaging/requestidentifier + """ + + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') + """ + The server environment, either sandbox or production. + + https://developer.apple.com/documentation/retentionmessaging/environment + """ + + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ + + signedDate: Optional[int] = attr.ib(default=None) + """ + The UNIX time, in milliseconds, that the App Store signed the JSON Web Signature (JWS) data. + + https://developer.apple.com/documentation/retentionmessaging/signeddate + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/RealtimeRequestBody.py b/appstoreserverlibrary/models/RealtimeRequestBody.py new file mode 100644 index 00000000..df06b9c6 --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeRequestBody.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr + +@define +class RealtimeRequestBody: + """ + The request body the App Store server sends to your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/realtimerequestbody + """ + + signedPayload: Optional[str] = attr.ib(default=None) + """ + The payload in JSON Web Signature (JWS) format, signed by the App Store. + + https://developer.apple.com/documentation/retentionmessaging/signedpayload + """ \ No newline at end of file diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 3e52b7ca..c7fe00d4 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -131,6 +131,30 @@ def verify_and_decode_app_transaction(self, signed_app_transaction: str) -> AppT raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) return decoded_app_transaction + def verify_and_decode_retention_request(self, signed_payload: str): + """ + Verifies and decodes a retention messaging request from the App Store. + Uses the same x509 certificate chain verification as other Apple notifications. + + :param signed_payload: The signedPayload field from the retention request + :return: The decoded retention request after verification + :throws VerificationException: Thrown if the data could not be verified + """ + from .models.DecodedRealtimeRequestBody import DecodedRealtimeRequestBody + + decoded_dict = self._decode_signed_object(signed_payload) + decoded_request = _get_cattrs_converter(DecodedRealtimeRequestBody).structure( + decoded_dict, DecodedRealtimeRequestBody + ) + + if self._app_apple_id is not None and decoded_request.appAppleId != self._app_apple_id: + raise VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) + + if decoded_request.environment != self._environment: + raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) + + return decoded_request + def _decode_signed_object(self, signed_obj: str) -> dict: try: decoded_jwt = jwt.decode(signed_obj, options={"verify_signature": False}) diff --git a/tests/resources/mock_signed_data/retentionRequest b/tests/resources/mock_signed_data/retentionRequest new file mode 100644 index 00000000..33b66c9f --- /dev/null +++ b/tests/resources/mock_signed_data/retentionRequest @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmlnaW5hbFRyYW5zYWN0aW9uSWQiOiIxMDAwMDAwMDAwMDAwMDAxIiwiYXBwQXBwbGVJZCI6MTIzNCwicHJvZHVjdElkIjoiY29tLmV4YW1wbGUuc3Vic2NyaXB0aW9uIiwibG9jYWxlIjoiZW5fVVMiLCJyZXF1ZXN0SWRlbnRpZmllciI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImVudmlyb25tZW50IjoiTG9jYWxUZXN0aW5nIiwic2lnbmVkRGF0ZSI6MTY4MTMxNDMyNDAwMH0.RvRAX6xYcozUaNIHLlL7saZX_Lk8Qlg3-INbBVPpwTVVRWmDCUlzlrTHBGKrbfVI73ppQPMXko5ChiwsDfOguQ \ No newline at end of file diff --git a/tests/test_payload_verification.py b/tests/test_payload_verification.py index 331d745f..ce808fd6 100644 --- a/tests/test_payload_verification.py +++ b/tests/test_payload_verification.py @@ -68,5 +68,25 @@ def test_malformed_jwt_with_malformed_data(self): verifier.verify_and_decode_notification("a.b.c") self.assertEqual(context.exception.status, VerificationStatus.VERIFICATION_FAILURE) + def test_retention_request_decoding(self): + verifier = get_signed_data_verifier(Environment.LOCAL_TESTING, "com.example") + retention_request = read_data_from_file('tests/resources/mock_signed_data/retentionRequest') + decoded_request = verifier.verify_and_decode_retention_request(retention_request) + self.assertEqual(decoded_request.originalTransactionId, "1000000000000001") + self.assertEqual(decoded_request.appAppleId, 1234) + self.assertEqual(decoded_request.productId, "com.example.subscription") + self.assertEqual(decoded_request.locale, "en_US") + self.assertEqual(decoded_request.requestIdentifier, "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(decoded_request.environment, Environment.LOCAL_TESTING) + self.assertEqual(decoded_request.signedDate, 1681314324000) + + + def test_retention_request_wrong_app_id(self): + verifier = get_signed_data_verifier(Environment.LOCAL_TESTING, "com.example", 5678) + retention_request = read_data_from_file('tests/resources/mock_signed_data/retentionRequest') + with self.assertRaises(VerificationException) as context: + verifier.verify_and_decode_retention_request(retention_request) + self.assertEqual(context.exception.status, VerificationStatus.INVALID_APP_IDENTIFIER) + if __name__ == '__main__': unittest.main() From 4e84155b5c8499c1a5aaf40baeea41381c686952 Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Fri, 3 Oct 2025 12:36:49 -0700 Subject: [PATCH 2/3] Fix verify_and_decode_retention_request return type annotation --- appstoreserverlibrary/signed_data_verifier.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index c7fe00d4..a14555ab 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -23,6 +23,7 @@ from .models.ResponseBodyV2DecodedPayload import ResponseBodyV2DecodedPayload from .models.JWSTransactionDecodedPayload import JWSTransactionDecodedPayload from .models.JWSRenewalInfoDecodedPayload import JWSRenewalInfoDecodedPayload +from .models.DecodedRealtimeRequestBody import DecodedRealtimeRequestBody class SignedDataVerifier: """ @@ -131,7 +132,7 @@ def verify_and_decode_app_transaction(self, signed_app_transaction: str) -> AppT raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) return decoded_app_transaction - def verify_and_decode_retention_request(self, signed_payload: str): + def verify_and_decode_retention_request(self, signed_payload: str) -> DecodedRealtimeRequestBody: """ Verifies and decodes a retention messaging request from the App Store. Uses the same x509 certificate chain verification as other Apple notifications. @@ -140,8 +141,6 @@ def verify_and_decode_retention_request(self, signed_payload: str): :return: The decoded retention request after verification :throws VerificationException: Thrown if the data could not be verified """ - from .models.DecodedRealtimeRequestBody import DecodedRealtimeRequestBody - decoded_dict = self._decode_signed_object(signed_payload) decoded_request = _get_cattrs_converter(DecodedRealtimeRequestBody).structure( decoded_dict, DecodedRealtimeRequestBody From 1427a6103369515a6fdfc8e6d10c418a572e2faf Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Wed, 15 Oct 2025 14:24:36 -0700 Subject: [PATCH 3/3] Fix userLocale parsing --- appstoreserverlibrary/models/DecodedRealtimeRequestBody.py | 2 +- tests/resources/mock_signed_data/retentionRequest | 2 +- tests/test_payload_verification.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py index 617d4eaa..3a3afcb6 100644 --- a/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py +++ b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py @@ -35,7 +35,7 @@ class DecodedRealtimeRequestBody(AttrsRawValueAware): https://developer.apple.com/documentation/retentionmessaging/productid """ - locale: Optional[str] = attr.ib(default=None) + userLocale: Optional[str] = attr.ib(default=None) """ The device's locale. diff --git a/tests/resources/mock_signed_data/retentionRequest b/tests/resources/mock_signed_data/retentionRequest index 33b66c9f..5e5a6645 100644 --- a/tests/resources/mock_signed_data/retentionRequest +++ b/tests/resources/mock_signed_data/retentionRequest @@ -1 +1 @@ -eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmlnaW5hbFRyYW5zYWN0aW9uSWQiOiIxMDAwMDAwMDAwMDAwMDAxIiwiYXBwQXBwbGVJZCI6MTIzNCwicHJvZHVjdElkIjoiY29tLmV4YW1wbGUuc3Vic2NyaXB0aW9uIiwibG9jYWxlIjoiZW5fVVMiLCJyZXF1ZXN0SWRlbnRpZmllciI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImVudmlyb25tZW50IjoiTG9jYWxUZXN0aW5nIiwic2lnbmVkRGF0ZSI6MTY4MTMxNDMyNDAwMH0.RvRAX6xYcozUaNIHLlL7saZX_Lk8Qlg3-INbBVPpwTVVRWmDCUlzlrTHBGKrbfVI73ppQPMXko5ChiwsDfOguQ \ No newline at end of file +eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmlnaW5hbFRyYW5zYWN0aW9uSWQiOiIxMDAwMDAwMDAwMDAwMDAxIiwiYXBwQXBwbGVJZCI6MTIzNCwicHJvZHVjdElkIjoiY29tLmV4YW1wbGUuc3Vic2NyaXB0aW9uIiwidXNlckxvY2FsZSI6ImVuLVVTIiwicmVxdWVzdElkZW50aWZpZXIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJlbnZpcm9ubWVudCI6IkxvY2FsVGVzdGluZyIsInNpZ25lZERhdGUiOjE2ODEzMTQzMjQwMDB9.RvRAX6xYcozUaNIHLlL7saZX_Lk8Qlg3-INbBVPpwTVVRWmDCUlzlrTHBGKrbfVI73ppQPMXko5ChiwsDfOguQ \ No newline at end of file diff --git a/tests/test_payload_verification.py b/tests/test_payload_verification.py index ce808fd6..41433e92 100644 --- a/tests/test_payload_verification.py +++ b/tests/test_payload_verification.py @@ -75,7 +75,7 @@ def test_retention_request_decoding(self): self.assertEqual(decoded_request.originalTransactionId, "1000000000000001") self.assertEqual(decoded_request.appAppleId, 1234) self.assertEqual(decoded_request.productId, "com.example.subscription") - self.assertEqual(decoded_request.locale, "en_US") + self.assertEqual(decoded_request.userLocale, "en-US") self.assertEqual(decoded_request.requestIdentifier, "550e8400-e29b-41d4-a716-446655440000") self.assertEqual(decoded_request.environment, Environment.LOCAL_TESTING) self.assertEqual(decoded_request.signedDate, 1681314324000)