diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 890cdfef..1289186f 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -319,6 +319,13 @@ class APIError(IntEnum): https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror """ + APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048 + """ + An error that indicates the endpoint doesn't support an app transaction ID. + + https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror + """ + SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004 """ An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. diff --git a/appstoreserverlibrary/jws_signature_creator.py b/appstoreserverlibrary/jws_signature_creator.py new file mode 100644 index 00000000..491e9f8b --- /dev/null +++ b/appstoreserverlibrary/jws_signature_creator.py @@ -0,0 +1,140 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import datetime +from typing import Any, Dict, Optional +import base64 +import json +import jwt +import uuid + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter + +class AdvancedCommerceAPIInAppRequest: + def __init__(self): + pass + +class JWSSignatureCreator: + def __init__(self, audience: str, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + self._audience = audience + self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend()) + self._key_id = key_id + self._issuer_id = issuer_id + self._bundle_id = bundle_id + + def _create_signature(self, feature_specific_claims: Dict[str, Any]) -> str: + claims = feature_specific_claims + current_time = datetime.datetime.now(datetime.timezone.utc) + + claims["bid"] = self._bundle_id + claims["iss"] = self._issuer_id + claims["aud"] = self._audience + claims["iat"] = current_time + claims["nonce"] = str(uuid.uuid4()) + + return jwt.encode(claims, + self._signing_key, + algorithm="ES256", + headers={"kid": self._key_id}, + ) + +class PromotionalOfferV2SignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create a PromotionalOfferV2SignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="promotional-offer", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, product_id: str, offer_identifier: str, transaction_id: Optional[str]) -> str: + """ + Create a promotional offer V2 signature. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param product_id: The unique identifier of the product + :param offer_identifier: The promotional offer identifier that you set up in App Store Connect + :param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended. + :return: The signed JWS. + """ + if product_id is None: + raise ValueError("product_id cannot be null") + if offer_identifier is None: + raise ValueError("offer_identifier cannot be null") + feature_specific_claims = { + "productId": product_id, + "offerIdentifier": offer_identifier + } + if transaction_id is not None: + feature_specific_claims["transactionId"] = transaction_id + return self._create_signature(feature_specific_claims=feature_specific_claims) + +class IntroductoryOfferEligibilitySignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create an IntroductoryOfferEligibilitySignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="introductory-offer-eligibility", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, product_id: str, allow_introductory_offer: bool, transaction_id: str) -> str: + """ + Create an introductory offer eligibility signature. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param product_id: The unique identifier of the product + :param allow_introductory_offer: A boolean value that determines whether the customer is eligible for an introductory offer + :param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. + :return: The signed JWS. + """ + if product_id is None: + raise ValueError("product_id cannot be null") + if allow_introductory_offer is None: + raise ValueError("allow_introductory_offer cannot be null") + if transaction_id is None: + raise ValueError("transaction_id cannot be null") + feature_specific_claims = { + "productId": product_id, + "allowIntroductoryOffer": allow_introductory_offer, + "transactionId": transaction_id + } + return self._create_signature(feature_specific_claims=feature_specific_claims) + +class AdvancedCommerceAPIInAppSignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create an AdvancedCommerceAPIInAppSignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="advanced-commerce-api", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, advanced_commerce_in_app_request: AdvancedCommerceAPIInAppRequest) -> str: + """ + Create an Advanced Commerce in-app signed request. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param advanced_commerce_in_app_request: The request to be signed. + :return: The signed JWS. + """ + if advanced_commerce_in_app_request is None: + raise ValueError("advanced_commerce_in_app_request cannot be null") + c = _get_cattrs_converter(type(advanced_commerce_in_app_request)) + request = c.unstructure(advanced_commerce_in_app_request) + encoded_request = base64.b64encode(json.dumps(request).encode(encoding='utf-8')).decode('utf-8') + feature_specific_claims = { + "request": encoded_request + } + return self._create_signature(feature_specific_claims=feature_specific_claims) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AppTransaction.py b/appstoreserverlibrary/models/AppTransaction.py index 7093a100..6d2c98c3 100644 --- a/appstoreserverlibrary/models/AppTransaction.py +++ b/appstoreserverlibrary/models/AppTransaction.py @@ -7,6 +7,7 @@ from .LibraryUtility import AttrsRawValueAware from .Environment import Environment +from .PurchasePlatform import PurchasePlatform @define class AppTransaction(AttrsRawValueAware): @@ -96,4 +97,23 @@ class AppTransaction(AttrsRawValueAware): The date the customer placed an order for the app before it's available in the App Store. https://developer.apple.com/documentation/storekit/apptransaction/4013175-preorderdate + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid + """ + + originalPlatform: Optional[PurchasePlatform] = PurchasePlatform.create_main_attr('rawOriginalPlatform') + """ + The platform on which the customer originally purchased the app. + + https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz + """ + + rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform') + """ + See originalPlatform """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 58c5d734..8a2d9ca6 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -174,4 +174,25 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): An array of win-back offer identifiers that a customer is eligible to redeem, which sorts the identifiers to present the better offers first. https://developer.apple.com/documentation/appstoreserverapi/eligiblewinbackofferids + """ + + appAccountToken: Optional[str] = attr.ib(default=None) + """ + The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appTransactionId + """ + + offerPeriod: Optional[str] = attr.ib(default=None) + """ + The duration of the offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerPeriod """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index b990b6f3..38fd0a6f 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -237,4 +237,18 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType') """ See offerDiscountType + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appTransactionId + """ + + offerPeriod: Optional[str] = attr.ib(default=None) + """ + The duration of the offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerPeriod """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/PurchasePlatform.py b/appstoreserverlibrary/models/PurchasePlatform.py new file mode 100644 index 00000000..9549c040 --- /dev/null +++ b/appstoreserverlibrary/models/PurchasePlatform.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class PurchasePlatform(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + Values that represent Apple platforms. + + https://developer.apple.com/documentation/storekit/appstore/platform + """ + IOS = "iOS" + MAC_OS = "macOS" + TV_OS = "tvOS" + VISION_OS = "visionOS" diff --git a/tests/resources/models/appTransaction.json b/tests/resources/models/appTransaction.json index b3b937b8..9313c31c 100644 --- a/tests/resources/models/appTransaction.json +++ b/tests/resources/models/appTransaction.json @@ -9,5 +9,7 @@ "originalApplicationVersion": "1.1.2", "deviceVerification": "device_verification_value", "deviceVerificationNonce": "48ccfa42-7431-4f22-9908-7e88983e105a", - "preorderDate": 1698148700000 + "preorderDate": 1698148700000, + "appTransactionId": "71134", + "originalPlatform": "iOS" } \ No newline at end of file diff --git a/tests/resources/models/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index 30b90d27..d4bf60be 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -19,5 +19,8 @@ "eligibleWinBackOfferIds": [ "eligible1", "eligible2" - ] + ], + "appTransactionId": "71134", + "offerPeriod": "P1Y", + "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138" } diff --git a/tests/resources/models/signedTransaction.json b/tests/resources/models/signedTransaction.json index 0211857b..f81bac8c 100644 --- a/tests/resources/models/signedTransaction.json +++ b/tests/resources/models/signedTransaction.json @@ -24,5 +24,7 @@ "storefrontId":"143441", "price": 10990, "currency": "USD", - "offerDiscountType": "PAY_AS_YOU_GO" + "offerDiscountType": "PAY_AS_YOU_GO", + "appTransactionId": "71134", + "offerPeriod": "P1Y" } \ No newline at end of file diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index aaea38ce..44709c47 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -11,6 +11,7 @@ from appstoreserverlibrary.models.OfferDiscountType import OfferDiscountType from appstoreserverlibrary.models.OfferType import OfferType from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.PurchasePlatform import PurchasePlatform from appstoreserverlibrary.models.RevocationReason import RevocationReason from appstoreserverlibrary.models.Status import Status from appstoreserverlibrary.models.Subtype import Subtype @@ -38,6 +39,9 @@ def test_app_transaction_decoding(self): self.assertEqual("device_verification_value", app_transaction.deviceVerification) self.assertEqual("48ccfa42-7431-4f22-9908-7e88983e105a", app_transaction.deviceVerificationNonce) self.assertEqual(1698148700000, app_transaction.preorderDate) + self.assertEqual("71134", app_transaction.appTransactionId) + self.assertEqual(PurchasePlatform.IOS, app_transaction.originalPlatform) + self.assertEqual("iOS", app_transaction.rawOriginalPlatform) def test_transaction_decoding(self): signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransaction.json') @@ -79,6 +83,8 @@ def test_transaction_decoding(self): self.assertEqual("USD", transaction.currency) self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, transaction.offerDiscountType) self.assertEqual("PAY_AS_YOU_GO", transaction.rawOfferDiscountType) + self.assertEqual("71134", transaction.appTransactionId) + self.assertEqual("P1Y", transaction.offerPeriod) def test_renewal_info_decoding(self): @@ -112,6 +118,9 @@ def test_renewal_info_decoding(self): self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, renewal_info.offerDiscountType) self.assertEqual("PAY_AS_YOU_GO", renewal_info.rawOfferDiscountType) self.assertEqual(['eligible1', 'eligible2'], renewal_info.eligibleWinBackOfferIds) + self.assertEqual("71134", renewal_info.appTransactionId) + self.assertEqual("P1Y", renewal_info.offerPeriod) + self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", renewal_info.appAccountToken) def test_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') diff --git a/tests/test_jws_signature_creator.py b/tests/test_jws_signature_creator.py new file mode 100644 index 00000000..857c83a3 --- /dev/null +++ b/tests/test_jws_signature_creator.py @@ -0,0 +1,128 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from attr import define +import attr +import base64 +import json +import jwt +import unittest +from appstoreserverlibrary.jws_signature_creator import AdvancedCommerceAPIInAppRequest, AdvancedCommerceAPIInAppSignatureCreator, IntroductoryOfferEligibilitySignatureCreator, PromotionalOfferV2SignatureCreator +from tests.util import read_data_from_binary_file + +@define +class TestInAppRequest(AdvancedCommerceAPIInAppRequest): + test_value: str + +class JWSSignatureCreatorTest(unittest.TestCase): + def test_promotional_offer_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", "offerIdentifier", "transactionId") + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("promotional-offer", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + self.assertEqual('productId', payload['productId']) + self.assertEqual('offerIdentifier', payload['offerIdentifier']) + self.assertEqual('transactionId', payload['transactionId']) + + def test_promotional_offer_signature_creator_transaction_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", "offerIdentifier", None) + payload = jwt.decode(signature, options={"verify_signature": False}) + self.assertFalse('transactionId' in payload) + + def test_promotional_offer_signature_creator_offer_identifier_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", None, "transactionId") + + def test_promotional_offer_signature_creator_product_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None, "offerIdentifier", "transactionId") + + def test_introductory_offer_eligibility_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", True, "transactionId") + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("introductory-offer-eligibility", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + self.assertEqual('productId', payload['productId']) + self.assertEqual(True, payload['allowIntroductoryOffer']) + self.assertEqual('transactionId', payload['transactionId']) + + def test_introductory_offer_eligibility_signature_creator_transaction_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", True, None) + + def test_introductory_offer_eligibility_signature_creator_allow_introductory_offer_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", None, "transactionId") + + def test_introductory_offer_eligibility_signature_creator_product_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None, True, "transactionId") + + def test_advanced_commerce_api_in_app_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = AdvancedCommerceAPIInAppSignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + request = TestInAppRequest("testValue") + signature = signature_creator.create_signature(request) + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("advanced-commerce-api", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + request = payload['request'] + decode_json = json.loads(str(base64.b64decode(request).decode('utf-8'))) + self.assertEqual('testValue', decode_json['test_value']) + + def test_advanced_commerce_api_in_app_signature_creator_request_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = AdvancedCommerceAPIInAppSignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None) \ No newline at end of file diff --git a/tests/test_promotional_offer_signature_creator.py b/tests/test_promotional_offer_signature_creator.py index 31cdb845..3b73310a 100644 --- a/tests/test_promotional_offer_signature_creator.py +++ b/tests/test_promotional_offer_signature_creator.py @@ -11,5 +11,5 @@ class PromotionalOfferSignatureCreatorTest(unittest.TestCase): def test_signature_creator(self): signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') signature_creator = PromotionalOfferSignatureCreator(signing_key, 'keyId', 'bundleId') - signature = signature_creator.create_signature("productId", "offerId", "applicationUsername", UUID("20fba8a0-2b80-4a7d-a17f-85c1854727f8"), 1698148900000) + signature = signature_creator.create_signature("productId", "offerId", "appAccountToken", UUID("20fba8a0-2b80-4a7d-a17f-85c1854727f8"), 1698148900000) self.assertIsNotNone(signature) \ No newline at end of file