Grant mobile plans from verified App Store and Play purchases (#78).
The app confirms a StoreKit signed transaction or Play purchase token. The backend checks it with Apple or Google before writing the ledger, and the RevenueCat webhook no longer grants access.
This commit is contained in:
1 parent
bb5c43a4e7
commit
74e8a4ad71
14 files changed
+1141
-119
No files matched your search
@@ -247,6 +247,8 @@ def needs_checkout(user) -> bool:
|
||||
UserSubscription.Source.ADMIN,
|
||||
UserSubscription.Source.STRIPE,
|
||||
UserSubscription.Source.REVENUECAT,
|
||||
UserSubscription.Source.APP_STORE,
|
||||
UserSubscription.Source.PLAY_STORE,
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -457,8 +459,9 @@ def assign_plan_from_revenuecat(
|
||||
cancel_at_period_end: bool | None = None,
|
||||
current_period_end=None,
|
||||
keep_existing_plan_if_unknown: bool = False,
|
||||
source: str | None = None,
|
||||
) -> UserSubscription:
|
||||
"""Assign a catalog plan from a RevenueCat store purchase event."""
|
||||
"""Assign a catalog plan from a verified store purchase."""
|
||||
seed_subscription_plans(update_existing=False)
|
||||
existing = (
|
||||
UserSubscription.objects.filter(user=user).select_related("plan").first()
|
||||
@@ -493,7 +496,7 @@ def assign_plan_from_revenuecat(
|
||||
sub = assign_plan(
|
||||
user,
|
||||
plan=plan,
|
||||
source=UserSubscription.Source.REVENUECAT,
|
||||
source=source or UserSubscription.Source.REVENUECAT,
|
||||
status=status,
|
||||
revenuecat_original_transaction_id=revenuecat_original_transaction_id or "",
|
||||
log_auth_event=False,
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""Verify App Store and Play Store purchases and grant entitlements.
|
||||
|
||||
The mobile app completes the purchase with StoreKit or Play Billing, then
|
||||
POSTs the signed transaction or purchase token. This module checks that
|
||||
payload with Apple or Google and writes the subscription ledger. Store
|
||||
webhooks are not accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from monetization.models import Invoice, Payment, UserSubscription
|
||||
from monetization.services.plans import (
|
||||
assign_plan_from_revenuecat,
|
||||
log_subscription_auth_event,
|
||||
resolve_plan_from_revenuecat_product,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# uuid5(NAMESPACE_URL, this name). Do not change; clients receive the result.
|
||||
_STORE_ACCOUNT_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL,
|
||||
"https://chat.aimloperations.com/store-account",
|
||||
)
|
||||
_APP_STORE_CERT_OID = x509.ObjectIdentifier("1.2.840.113635.100.6.11.1")
|
||||
_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
||||
_BUNDLED_APPLE_ROOT = (
|
||||
Path(__file__).resolve().parent.parent / "certs" / "apple_root_g3.pem"
|
||||
)
|
||||
|
||||
_STORE_SOURCES = frozenset(
|
||||
{
|
||||
UserSubscription.Source.APP_STORE,
|
||||
UserSubscription.Source.PLAY_STORE,
|
||||
UserSubscription.Source.REVENUECAT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StoreNotConfiguredError(Exception):
|
||||
"""Apple or Google credentials required to verify a purchase are missing."""
|
||||
|
||||
|
||||
class StorePurchaseRejected(Exception):
|
||||
"""The store payload is not an active purchase for this user."""
|
||||
|
||||
|
||||
class StoreVerificationError(Exception):
|
||||
"""The store API could not be reached."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedStorePurchase:
|
||||
source: str
|
||||
store_label: str
|
||||
product_id: str
|
||||
transaction_id: str
|
||||
original_transaction_id: str
|
||||
expires_at: datetime | None
|
||||
purchased_at: datetime | None
|
||||
cancel_at_period_end: bool
|
||||
status: str
|
||||
price_cents: int
|
||||
currency: str
|
||||
account_token: str
|
||||
|
||||
|
||||
def store_account_token(user_id: int) -> str:
|
||||
"""Stable store account id for this user. Pass it to StoreKit and Play."""
|
||||
return str(uuid.uuid5(_STORE_ACCOUNT_NAMESPACE, str(user_id)))
|
||||
|
||||
|
||||
def expire_lapsed_store_subscription(sub: UserSubscription | None):
|
||||
"""End store access once the verified period has passed.
|
||||
|
||||
Renewals arrive when the app confirms the store entitlement again.
|
||||
"""
|
||||
if sub is None or sub.source not in _STORE_SOURCES:
|
||||
return sub
|
||||
if sub.status != UserSubscription.Status.ACTIVE or not sub.current_period_end:
|
||||
return sub
|
||||
if sub.current_period_end > timezone.now():
|
||||
return sub
|
||||
prev_status = sub.status
|
||||
sub.status = UserSubscription.Status.CANCELED
|
||||
sub.cancel_at_period_end = False
|
||||
sub.save(update_fields=["status", "cancel_at_period_end", "last_modified"])
|
||||
if prev_status != UserSubscription.Status.CANCELED:
|
||||
log_subscription_auth_event(
|
||||
sub.user,
|
||||
started=False,
|
||||
detail=(
|
||||
f"plan={sub.plan.slug if sub.plan_id else 'none'} "
|
||||
f"source={sub.source} status={sub.status} "
|
||||
"reason=store_period_ended"
|
||||
),
|
||||
)
|
||||
return sub
|
||||
|
||||
|
||||
def confirm_store_purchase(user, payload: dict[str, Any]) -> UserSubscription:
|
||||
"""Verify one store purchase for the authenticated user and grant it."""
|
||||
store = (payload.get("store") or "").strip().lower()
|
||||
product_id = (payload.get("product_id") or "").strip()
|
||||
if store == UserSubscription.Source.APP_STORE:
|
||||
verified = verify_apple_signed_transaction(
|
||||
signed_transaction=(payload.get("signed_transaction") or "").strip(),
|
||||
product_id=product_id,
|
||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
||||
)
|
||||
elif store == UserSubscription.Source.PLAY_STORE:
|
||||
verified = verify_google_subscription(
|
||||
product_id=product_id,
|
||||
purchase_token=(payload.get("purchase_token") or "").strip(),
|
||||
transaction_id=(payload.get("transaction_id") or "").strip(),
|
||||
)
|
||||
else:
|
||||
raise StorePurchaseRejected("store must be app_store or play_store.")
|
||||
if verified.product_id != product_id:
|
||||
raise StorePurchaseRejected("Store product does not match this purchase.")
|
||||
return grant_verified_store_purchase(user, verified)
|
||||
|
||||
|
||||
def verify_apple_signed_transaction(
|
||||
*,
|
||||
signed_transaction: str,
|
||||
product_id: str,
|
||||
transaction_id: str = "",
|
||||
) -> VerifiedStorePurchase:
|
||||
"""Verify a StoreKit 2 signed transaction against Apple's root."""
|
||||
if not signed_transaction:
|
||||
raise StorePurchaseRejected("App Store purchases require signed_transaction.")
|
||||
bundle_id = (getattr(settings, "APPLE_IAP_BUNDLE_ID", "") or "").strip()
|
||||
if not bundle_id:
|
||||
raise StoreNotConfiguredError("APPLE_IAP_BUNDLE_ID is not configured.")
|
||||
roots = _load_apple_roots()
|
||||
if not roots:
|
||||
raise StoreNotConfiguredError("No Apple root certificate is configured.")
|
||||
|
||||
try:
|
||||
header = jwt.get_unverified_header(signed_transaction)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise StorePurchaseRejected("Invalid App Store signed transaction.") from exc
|
||||
x5c = header.get("x5c") if isinstance(header, dict) else None
|
||||
if not isinstance(x5c, list) or not x5c:
|
||||
raise StorePurchaseRejected("App Store transaction is missing its certificate.")
|
||||
try:
|
||||
certs = [_load_der_cert(item) for item in x5c]
|
||||
leaf = _trusted_leaf(certs, roots)
|
||||
except StorePurchaseRejected:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.") from exc
|
||||
_require_app_store_oid(leaf)
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
signed_transaction,
|
||||
leaf.public_key(),
|
||||
algorithms=["ES256"],
|
||||
options={"verify_aud": False, "verify_exp": False},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise StorePurchaseRejected("App Store transaction signature is invalid.") from exc
|
||||
if not isinstance(claims, dict):
|
||||
raise StorePurchaseRejected("App Store transaction payload is invalid.")
|
||||
|
||||
claim_bundle = str(claims.get("bundleId") or "")
|
||||
if claim_bundle != bundle_id:
|
||||
raise StorePurchaseRejected("App Store bundle id does not match this app.")
|
||||
claim_product = str(claims.get("productId") or "")
|
||||
if product_id and claim_product != product_id:
|
||||
raise StorePurchaseRejected("App Store product does not match this purchase.")
|
||||
claim_txn = str(claims.get("transactionId") or "")
|
||||
if transaction_id and claim_txn and transaction_id != claim_txn:
|
||||
raise StorePurchaseRejected("App Store transaction id does not match.")
|
||||
if not claim_txn:
|
||||
raise StorePurchaseRejected("App Store transaction id is missing.")
|
||||
|
||||
expires_at = _ms_to_dt(claims.get("expiresDate"))
|
||||
purchased_at = _ms_to_dt(claims.get("purchaseDate"))
|
||||
revoked = _ms_to_dt(claims.get("revocationDate")) is not None
|
||||
now = timezone.now()
|
||||
if revoked or (expires_at is not None and expires_at <= now):
|
||||
status = UserSubscription.Status.CANCELED
|
||||
else:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
|
||||
price_cents = 0
|
||||
raw_price = claims.get("price")
|
||||
try:
|
||||
if raw_price is not None:
|
||||
price_cents = max(0, int(raw_price) // 10)
|
||||
except (TypeError, ValueError):
|
||||
price_cents = 0
|
||||
currency = str(claims.get("currency") or "usd").lower()
|
||||
original = str(claims.get("originalTransactionId") or claim_txn)
|
||||
return VerifiedStorePurchase(
|
||||
source=UserSubscription.Source.APP_STORE,
|
||||
store_label="APP_STORE",
|
||||
product_id=claim_product,
|
||||
transaction_id=claim_txn,
|
||||
original_transaction_id=original,
|
||||
expires_at=expires_at,
|
||||
purchased_at=purchased_at,
|
||||
cancel_at_period_end=False,
|
||||
status=status,
|
||||
price_cents=price_cents,
|
||||
currency=currency,
|
||||
account_token=str(claims.get("appAccountToken") or ""),
|
||||
)
|
||||
|
||||
|
||||
def verify_google_subscription(
|
||||
*,
|
||||
product_id: str,
|
||||
purchase_token: str,
|
||||
transaction_id: str = "",
|
||||
) -> VerifiedStorePurchase:
|
||||
"""Verify a Play Billing purchase token with the Android Publisher API."""
|
||||
if not purchase_token:
|
||||
raise StorePurchaseRejected("Play Store purchases require purchase_token.")
|
||||
if not product_id:
|
||||
raise StorePurchaseRejected("Play Store purchases require product_id.")
|
||||
body = fetch_google_subscription(purchase_token)
|
||||
return _verified_from_google(
|
||||
body,
|
||||
product_id=product_id,
|
||||
purchase_token=purchase_token,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
|
||||
def fetch_google_subscription(purchase_token: str) -> dict[str, Any]:
|
||||
"""GET purchases.subscriptionsv2 for this token. Tests patch this."""
|
||||
package_name = (getattr(settings, "GOOGLE_PLAY_PACKAGE_NAME", "") or "").strip()
|
||||
raw = (getattr(settings, "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") or "").strip()
|
||||
if not package_name or not raw:
|
||||
raise StoreNotConfiguredError(
|
||||
"Google Play verification is not configured "
|
||||
"(GOOGLE_PLAY_PACKAGE_NAME and GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)."
|
||||
)
|
||||
info = _load_service_account_info(raw)
|
||||
try:
|
||||
access_token = _google_access_token(info)
|
||||
url = (
|
||||
"https://androidpublisher.googleapis.com/androidpublisher/v3/"
|
||||
f"applications/{quote(package_name, safe='')}/purchases/"
|
||||
f"subscriptionsv2/tokens/{quote(purchase_token, safe='')}"
|
||||
)
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
except (requests.RequestException, ValueError, OSError) as exc:
|
||||
logger.exception("Play Store verification request failed")
|
||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
||||
if response.status_code == 404:
|
||||
raise StorePurchaseRejected("Play Store purchase was not found.")
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Play Store verification HTTP %s",
|
||||
response.status_code,
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
raise StoreVerificationError("Play Store verification failed.")
|
||||
raise StorePurchaseRejected("Play Store rejected this purchase token.")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise StoreVerificationError("Play Store returned an invalid response.") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise StoreVerificationError("Play Store returned an invalid response.")
|
||||
return body
|
||||
|
||||
|
||||
def _verified_from_google(
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
product_id: str,
|
||||
purchase_token: str,
|
||||
transaction_id: str,
|
||||
) -> VerifiedStorePurchase:
|
||||
line_items = body.get("lineItems") or []
|
||||
match = None
|
||||
if isinstance(line_items, list):
|
||||
for item in line_items:
|
||||
if isinstance(item, dict) and str(item.get("productId") or "") == product_id:
|
||||
match = item
|
||||
break
|
||||
if match is None:
|
||||
raise StorePurchaseRejected("Play Store product does not match this purchase.")
|
||||
|
||||
state = str(body.get("subscriptionState") or "")
|
||||
expires_at = _parse_rfc3339(match.get("expiryTime"))
|
||||
now = timezone.now()
|
||||
expired = expires_at is not None and expires_at <= now
|
||||
cancel_at_period_end = False
|
||||
if state in {"SUBSCRIPTION_STATE_PENDING", "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED"}:
|
||||
raise StorePurchaseRejected("Play Store purchase is still pending.")
|
||||
if state in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} and not expired:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
elif state == "SUBSCRIPTION_STATE_CANCELED" and not expired:
|
||||
status = UserSubscription.Status.ACTIVE
|
||||
cancel_at_period_end = True
|
||||
elif state == "SUBSCRIPTION_STATE_ON_HOLD":
|
||||
status = UserSubscription.Status.PAST_DUE
|
||||
else:
|
||||
status = UserSubscription.Status.CANCELED
|
||||
|
||||
auto_renew = (match.get("autoRenewingPlan") or {}) if isinstance(match, dict) else {}
|
||||
if isinstance(auto_renew, dict) and auto_renew.get("autoRenewEnabled") is False and not expired:
|
||||
if status == UserSubscription.Status.ACTIVE:
|
||||
cancel_at_period_end = True
|
||||
|
||||
order_id = str(match.get("latestSuccessfulOrderId") or transaction_id or purchase_token)
|
||||
identifiers = body.get("externalAccountIdentifiers") or {}
|
||||
account_token = ""
|
||||
if isinstance(identifiers, dict):
|
||||
account_token = str(identifiers.get("obfuscatedExternalAccountId") or "")
|
||||
price_cents, currency = _google_price(auto_renew if isinstance(auto_renew, dict) else {})
|
||||
return VerifiedStorePurchase(
|
||||
source=UserSubscription.Source.PLAY_STORE,
|
||||
store_label="PLAY_STORE",
|
||||
product_id=product_id,
|
||||
transaction_id=order_id,
|
||||
original_transaction_id=purchase_token,
|
||||
expires_at=expires_at,
|
||||
purchased_at=None,
|
||||
cancel_at_period_end=cancel_at_period_end,
|
||||
status=status,
|
||||
price_cents=price_cents,
|
||||
currency=currency,
|
||||
account_token=account_token,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def grant_verified_store_purchase(user, verified: VerifiedStorePurchase) -> UserSubscription:
|
||||
plan = resolve_plan_from_revenuecat_product(verified.product_id)
|
||||
if plan is None:
|
||||
raise StorePurchaseRejected("This store product is not mapped to a plan.")
|
||||
|
||||
expected = store_account_token(user.pk)
|
||||
if verified.account_token and verified.account_token.lower() != expected.lower():
|
||||
raise StorePurchaseRejected("Purchase account token does not match this user.")
|
||||
|
||||
original = verified.original_transaction_id
|
||||
if original:
|
||||
taken = (
|
||||
UserSubscription.objects.select_for_update()
|
||||
.filter(revenuecat_original_transaction_id=original)
|
||||
.exclude(user=user)
|
||||
.exists()
|
||||
)
|
||||
if taken:
|
||||
raise StorePurchaseRejected(
|
||||
"This purchase is already linked to another account."
|
||||
)
|
||||
|
||||
sub = assign_plan_from_revenuecat(
|
||||
user,
|
||||
plan_slug=plan.slug,
|
||||
product_id=verified.product_id,
|
||||
revenuecat_original_transaction_id=original,
|
||||
status=verified.status,
|
||||
cancel_at_period_end=verified.cancel_at_period_end,
|
||||
current_period_end=verified.expires_at,
|
||||
keep_existing_plan_if_unknown=False,
|
||||
source=verified.source,
|
||||
)
|
||||
|
||||
if verified.status != UserSubscription.Status.ACTIVE:
|
||||
return sub
|
||||
|
||||
amount = verified.price_cents or plan.price_cents
|
||||
currency = (verified.currency or plan.currency or "usd").lower()
|
||||
event_id = f"{verified.source}:{verified.transaction_id}"
|
||||
invoice, _created = Invoice.objects.update_or_create(
|
||||
revenuecat_event_id=event_id,
|
||||
defaults={
|
||||
"user": user,
|
||||
"company": getattr(user, "company", None),
|
||||
"provider": verified.source,
|
||||
"status": Invoice.Status.PAID,
|
||||
"currency": currency,
|
||||
"amount_due": amount,
|
||||
"amount_paid": amount,
|
||||
"period_start": verified.purchased_at,
|
||||
"period_end": verified.expires_at,
|
||||
"revenuecat_store": verified.store_label,
|
||||
"description": f"{plan.name} — {verified.store_label}",
|
||||
"hosted_invoice_url": "",
|
||||
},
|
||||
)
|
||||
Payment.objects.update_or_create(
|
||||
revenuecat_transaction_id=verified.transaction_id,
|
||||
defaults={
|
||||
"user": user,
|
||||
"company": getattr(user, "company", None),
|
||||
"invoice": invoice,
|
||||
"provider": verified.source,
|
||||
"status": Payment.Status.SUCCEEDED,
|
||||
"currency": currency,
|
||||
"amount": amount,
|
||||
"paid_at": verified.purchased_at or timezone.now(),
|
||||
"failure_message": "",
|
||||
},
|
||||
)
|
||||
return sub
|
||||
|
||||
|
||||
def _load_apple_roots() -> list[x509.Certificate]:
|
||||
pems: list[str] = []
|
||||
extra = (getattr(settings, "APPLE_IAP_ROOT_PEM", "") or "").strip()
|
||||
if extra:
|
||||
pems.append(extra)
|
||||
if _BUNDLED_APPLE_ROOT.is_file():
|
||||
pems.append(_BUNDLED_APPLE_ROOT.read_text(encoding="utf-8"))
|
||||
roots: list[x509.Certificate] = []
|
||||
for pem in pems:
|
||||
try:
|
||||
roots.append(x509.load_pem_x509_certificate(pem.encode("utf-8")))
|
||||
except ValueError:
|
||||
logger.error("Ignoring an unreadable Apple root certificate")
|
||||
return roots
|
||||
|
||||
|
||||
def _load_der_cert(value: Any) -> x509.Certificate:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise StorePurchaseRejected("App Store certificate is missing.")
|
||||
try:
|
||||
der = base64.b64decode(value)
|
||||
return x509.load_der_x509_certificate(der)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise StorePurchaseRejected("App Store certificate is invalid.") from exc
|
||||
|
||||
|
||||
def _trusted_leaf(
|
||||
certs: list[x509.Certificate],
|
||||
roots: list[x509.Certificate],
|
||||
) -> x509.Certificate:
|
||||
if not certs:
|
||||
raise StorePurchaseRejected("App Store certificate chain is empty.")
|
||||
now = datetime.now(dt_timezone.utc)
|
||||
for cert in certs:
|
||||
if cert.not_valid_before_utc > now or cert.not_valid_after_utc < now:
|
||||
raise StorePurchaseRejected("App Store certificate is not currently valid.")
|
||||
for index in range(len(certs) - 1):
|
||||
_verify_signed_by(certs[index], certs[index + 1])
|
||||
if certs[index].issuer != certs[index + 1].subject:
|
||||
raise StorePurchaseRejected("App Store certificate issuer mismatch.")
|
||||
last = certs[-1]
|
||||
root_fps = {root.fingerprint(hashes.SHA256()) for root in roots}
|
||||
if last.fingerprint(hashes.SHA256()) in root_fps:
|
||||
return certs[0]
|
||||
for root in roots:
|
||||
try:
|
||||
_verify_signed_by(last, root)
|
||||
except Exception:
|
||||
continue
|
||||
if last.issuer == root.subject:
|
||||
return certs[0]
|
||||
raise StorePurchaseRejected("App Store certificate chain is not trusted.")
|
||||
|
||||
|
||||
def _verify_signed_by(child: x509.Certificate, issuer: x509.Certificate) -> None:
|
||||
public_key = issuer.public_key()
|
||||
signature_hash = child.signature_hash_algorithm
|
||||
if signature_hash is None:
|
||||
raise StorePurchaseRejected("App Store certificate has no signature hash.")
|
||||
if isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
ec.ECDSA(signature_hash),
|
||||
)
|
||||
return
|
||||
public_key.verify(
|
||||
child.signature,
|
||||
child.tbs_certificate_bytes,
|
||||
padding.PKCS1v15(),
|
||||
signature_hash,
|
||||
)
|
||||
|
||||
|
||||
def _require_app_store_oid(leaf: x509.Certificate) -> None:
|
||||
try:
|
||||
leaf.extensions.get_extension_for_oid(_APP_STORE_CERT_OID)
|
||||
except x509.ExtensionNotFound as exc:
|
||||
raise StorePurchaseRejected(
|
||||
"Signed transaction certificate is not an App Store certificate."
|
||||
) from exc
|
||||
|
||||
|
||||
def _google_access_token(info: dict[str, Any]) -> str:
|
||||
"""OAuth JWT bearer grant for a Play Console service account."""
|
||||
client_email = info.get("client_email")
|
||||
private_key = info.get("private_key")
|
||||
token_uri = info.get("token_uri") or "https://oauth2.googleapis.com/token"
|
||||
if not client_email or not private_key:
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is missing client_email or private_key."
|
||||
)
|
||||
now = int(time.time())
|
||||
assertion = jwt.encode(
|
||||
{
|
||||
"iss": client_email,
|
||||
"scope": _PLAY_SCOPE,
|
||||
"aud": token_uri,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
try:
|
||||
response = requests.post(
|
||||
token_uri,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": assertion,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise StoreVerificationError("Play Store verification failed.") from exc
|
||||
if response.status_code >= 400:
|
||||
raise StoreVerificationError("Play Store authentication failed.")
|
||||
try:
|
||||
token = response.json().get("access_token")
|
||||
except ValueError as exc:
|
||||
raise StoreVerificationError("Play Store authentication failed.") from exc
|
||||
if not token:
|
||||
raise StoreVerificationError("Play Store authentication failed.")
|
||||
return str(token)
|
||||
|
||||
|
||||
def _load_service_account_info(raw: str) -> dict[str, Any]:
|
||||
text = raw
|
||||
if not raw.lstrip().startswith("{"):
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
info = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not valid JSON."
|
||||
) from exc
|
||||
if not isinstance(info, dict):
|
||||
raise StoreNotConfiguredError(
|
||||
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be a JSON object."
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _ms_to_dt(value: Any) -> datetime | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
ms = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ms <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(ms / 1000.0, tz=dt_timezone.utc)
|
||||
|
||||
|
||||
def _parse_rfc3339(value: Any) -> datetime | None:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=dt_timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _google_price(auto_renew: dict[str, Any]) -> tuple[int, str]:
|
||||
price = auto_renew.get("recurringPrice") if isinstance(auto_renew, dict) else None
|
||||
if not isinstance(price, dict):
|
||||
return 0, "usd"
|
||||
currency = str(price.get("currencyCode") or "usd").lower()
|
||||
try:
|
||||
units = int(price.get("units") or 0)
|
||||
except (TypeError, ValueError):
|
||||
units = 0
|
||||
try:
|
||||
nanos = int(price.get("nanos") or 0)
|
||||
except (TypeError, ValueError):
|
||||
nanos = 0
|
||||
cents = units * 100 + nanos // 10_000_000
|
||||
return max(0, cents), currency
|
||||
Reference in new issue
Block a user