Skip to content
Open
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
69 changes: 69 additions & 0 deletions appstoreserverlibrary/models/DecodedRealtimeRequestBody.py
Original file line number Diff line number Diff line change
@@ -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
"""
20 changes: 20 additions & 0 deletions appstoreserverlibrary/models/RealtimeRequestBody.py
Original file line number Diff line number Diff line change
@@ -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
"""
23 changes: 23 additions & 0 deletions appstoreserverlibrary/signed_data_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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})
Expand Down
1 change: 1 addition & 0 deletions tests/resources/mock_signed_data/retentionRequest
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmlnaW5hbFRyYW5zYWN0aW9uSWQiOiIxMDAwMDAwMDAwMDAwMDAxIiwiYXBwQXBwbGVJZCI6MTIzNCwicHJvZHVjdElkIjoiY29tLmV4YW1wbGUuc3Vic2NyaXB0aW9uIiwibG9jYWxlIjoiZW5fVVMiLCJyZXF1ZXN0SWRlbnRpZmllciI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImVudmlyb25tZW50IjoiTG9jYWxUZXN0aW5nIiwic2lnbmVkRGF0ZSI6MTY4MTMxNDMyNDAwMH0.RvRAX6xYcozUaNIHLlL7saZX_Lk8Qlg3-INbBVPpwTVVRWmDCUlzlrTHBGKrbfVI73ppQPMXko5ChiwsDfOguQ
20 changes: 20 additions & 0 deletions tests/test_payload_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()