diff --git a/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py new file mode 100644 index 00000000..3a3afcb6 --- /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 + """ + + userLocale: 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..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,6 +132,28 @@ 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) -> DecodedRealtimeRequestBody: + """ + 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 + """ + 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..5e5a6645 --- /dev/null +++ b/tests/resources/mock_signed_data/retentionRequest @@ -0,0 +1 @@ +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 331d745f..41433e92 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.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) + + + 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()