Files

412 lines
16 KiB
Python

"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
from __future__ import annotations
import base64
import datetime
import json
import time
from typing import Any
import httpx
import jwt as pyjwt
import structlog
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.x509 import load_der_x509_certificate
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
from app.config import settings
logger = structlog.get_logger(__name__)
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
# https://www.apple.com/certificateauthority/
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
# Apple WWDR Intermediate Certificate OID
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
class AppleIAPService:
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
def _get_base_url(self, environment: str | None = None) -> str:
env = environment or settings.APPLE_IAP_ENVIRONMENT
if env == 'Sandbox':
return SANDBOX_BASE_URL
return PRODUCTION_BASE_URL
def _generate_jwt(self) -> str:
"""Generate a fresh ES256 JWT for App Store Server API authentication.
Apple recommends generating a new JWT for each request.
"""
private_key = settings.get_apple_iap_private_key()
if not private_key:
raise ValueError('Apple IAP private key is not configured')
now = int(time.time())
payload = {
'iss': settings.APPLE_IAP_ISSUER_ID,
'iat': now,
'exp': now + 3600,
'aud': 'appstoreconnect-v1',
'bid': settings.APPLE_IAP_BUNDLE_ID,
}
headers = {
'alg': 'ES256',
'kid': settings.APPLE_IAP_KEY_ID,
'typ': 'JWT',
}
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
async def _fetch_transaction(
self,
transaction_id: str,
base_url: str,
) -> httpx.Response | None:
"""Send a GET request to Apple's transaction lookup endpoint."""
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
token = self._generate_jwt()
async with httpx.AsyncClient(timeout=30) as client:
try:
return await client.get(
url,
headers={'Authorization': f'Bearer {token}'},
)
except httpx.RequestError as e:
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
return None
async def verify_transaction(
self,
transaction_id: str,
environment: str | None = None,
) -> dict[str, Any] | None:
"""Verify a transaction with Apple's App Store Server API.
Follows Apple's recommendation: if the configured environment returns
a 4xx error, retries against the opposite environment. This ensures
Sandbox purchases made during App Review still verify when the server
is configured for Production.
"""
primary_url = self._get_base_url(environment)
# Determine fallback URL (opposite environment)
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
for attempt_url in (primary_url, fallback_url):
response = await self._fetch_transaction(transaction_id, attempt_url)
if response is None:
return None # network error -- don't retry
if response.status_code == 200:
return self._parse_transaction_response(response, transaction_id)
# 4xx on primary -> retry on fallback per Apple docs
if 400 <= response.status_code < 500 and attempt_url == primary_url:
logger.info(
'Apple API returned 4xx on primary env, retrying fallback',
status=response.status_code,
primary=attempt_url,
fallback=fallback_url,
transaction_id=transaction_id,
)
continue
# Log the final failure
self._log_api_error(response, transaction_id)
return None
return None
def _parse_transaction_response(
self,
response: httpx.Response,
transaction_id: str,
) -> dict[str, Any] | None:
"""Extract and verify signedTransactionInfo from a 200 response."""
data = response.json()
signed_transaction_info = data.get('signedTransactionInfo')
if signed_transaction_info:
decoded = self._verify_and_decode_jws(signed_transaction_info)
if decoded:
return decoded
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
return None
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
return None
@staticmethod
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
if response.status_code == 404:
logger.warning('Apple transaction not found', transaction_id=transaction_id)
elif response.status_code == 401:
logger.error('Apple API auth failed -- check key configuration')
elif response.status_code == 429:
logger.warning('Apple API rate limit exceeded')
else:
logger.error(
'Apple API unexpected status',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
"""Validate decoded transaction info fields.
Returns None if valid, or an error message string.
"""
bundle_id = txn_info.get('bundleId')
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
return f'Bundle ID mismatch: {bundle_id}'
product_id = txn_info.get('productId')
if product_id != expected_product_id:
return f'Product ID mismatch: {product_id} != {expected_product_id}'
txn_type = txn_info.get('type')
if txn_type != 'Consumable':
return f'Unexpected transaction type: {txn_type}'
if txn_info.get('revocationDate'):
return f'Transaction was revoked at {txn_info["revocationDate"]}'
return None
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
Returns the decoded payload dict, or None if verification fails.
Used for both outer notification payloads and inner signed data
(signedTransactionInfo, signedRenewalInfo).
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
logger.warning('Invalid JWS format: expected 3 parts')
return None
# Decode header to get x5c chain
header_b64 = parts[0]
padding = 4 - len(header_b64) % 4
if padding != 4:
header_b64 += '=' * padding
header_json = base64.urlsafe_b64decode(header_b64)
header = json.loads(header_json)
x5c_chain = header.get('x5c', [])
if not x5c_chain:
logger.warning('No x5c certificate chain in JWS header')
return None
# Verify the certificate chain
if not self._verify_x5c_chain(x5c_chain):
logger.warning('x5c certificate chain verification failed')
return None
# Verify the signature using the leaf certificate
leaf_cert_der = base64.b64decode(x5c_chain[0])
leaf_cert = load_der_x509_certificate(leaf_cert_der)
public_key = leaf_cert.public_key()
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
signature_b64 = parts[2]
sig_padding = 4 - len(signature_b64) % 4
if sig_padding != 4:
signature_b64 += '=' * sig_padding
signature = base64.urlsafe_b64decode(signature_b64)
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
if len(signature) == 64:
r = int.from_bytes(signature[:32], 'big')
s = int.from_bytes(signature[32:], 'big')
signature = asym_utils.encode_dss_signature(r, s)
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
# Signature valid -- decode payload
return self._decode_jws_payload(jws_token)
except Exception as e:
logger.error('JWS verification failed', error=str(e), exc_info=True)
return None
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
"""Verify and decode an App Store Server Notification V2 payload.
Verifies the JWS x5c certificate chain, then returns the decoded payload.
Returns None if verification fails.
"""
return self._verify_and_decode_jws(signed_payload)
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
"""Verify the x5c certificate chain ends with an Apple Root CA."""
try:
if len(x5c_chain) < 2:
logger.warning('x5c chain too short', length=len(x5c_chain))
return False
certs = []
for cert_b64 in x5c_chain:
cert_der = base64.b64decode(cert_b64)
cert = load_der_x509_certificate(cert_der)
certs.append(cert)
# Check certificate validity periods
now = datetime.datetime.now(datetime.UTC)
for i, cert in enumerate(certs):
if now < cert.not_valid_before_utc:
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
return False
if now > cert.not_valid_after_utc:
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
return False
# Pin the root (last) certificate by SHA-256 fingerprint
root_cert = certs[-1]
root_fingerprint = root_cert.fingerprint(SHA256())
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
logger.warning(
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
got=root_fingerprint.hex(),
)
return False
# Verify each certificate is signed by the next one in the chain
for i in range(len(certs) - 1):
child = certs[i]
parent = certs[i + 1]
parent_public_key = parent.public_key()
parent_public_key.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(child.signature_hash_algorithm),
)
# FIX 3: Validate Apple WWDR intermediate OID
# The intermediate cert (index 1) must contain the Apple WWDR OID
# to ensure it is a genuine Apple WWDR intermediate certificate.
if len(certs) >= 2:
intermediate_cert = certs[1]
try:
# Check for the Apple WWDR OID in certificate extensions
found_apple_oid = False
for ext in intermediate_cert.extensions:
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
for policy in ext.value:
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
found_apple_oid = True
break
if found_apple_oid:
break
if not found_apple_oid:
logger.warning(
'Intermediate cert missing Apple WWDR OID',
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
)
return False
except x509.ExtensionNotFound:
logger.warning('Intermediate cert has no certificate policies extension')
return False
return True
except Exception as e:
logger.error('x5c chain verification error', error=str(e))
return False
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
"""Decode the payload from a JWS token without signature verification.
Use only after the signature has already been verified.
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
return None
payload_b64 = parts[1]
# Add base64url padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += '=' * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
return json.loads(payload_json)
except Exception as e:
logger.error('Failed to decode JWS payload', error=str(e))
return None
async def send_consumption_info(
self,
transaction_id: str,
customer_consented: bool,
consumption_status: int = 0,
delivery_status: int = 0,
lifetime_dollars_purchased: int = 0,
lifetime_dollars_refunded: int = 0,
platform: int = 1,
play_time: int = 0,
sample_content_provided: bool = False,
user_status: int = 0,
environment: str | None = None,
refund_preference: int | None = None,
) -> bool:
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
Must be sent within 12 hours of receiving the notification.
"""
base_url = self._get_base_url(environment)
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
token = self._generate_jwt()
body: dict[str, Any] = {
'customerConsented': customer_consented,
'consumptionStatus': consumption_status,
'deliveryStatus': delivery_status,
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
'platform': platform,
'playTime': play_time,
'sampleContentProvided': sample_content_provided,
'userStatus': user_status,
}
if refund_preference is not None:
body['refundPreference'] = refund_preference
async with httpx.AsyncClient(timeout=30) as client:
try:
response = await client.put(
url,
json=body,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
},
)
except httpx.RequestError as e:
logger.error('Apple consumption API request failed', error=str(e))
return False
if response.status_code == 202:
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
return True
logger.error(
'Apple consumption API error',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
return False