package services import ( "context" "crypto/ecdsa" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/oauth2/google" "google.golang.org/api/androidpublisher/v3" "google.golang.org/api/option" "github.com/treytartt/casera-api/internal/config" ) // IAP validation errors var ( ErrIAPNotConfigured = errors.New("IAP validation not configured") ErrInvalidReceipt = errors.New("invalid receipt data") ErrInvalidPurchaseToken = errors.New("invalid purchase token") ErrSubscriptionExpired = errors.New("subscription has expired") ErrSubscriptionCancelled = errors.New("subscription was cancelled") ErrInvalidProductID = errors.New("invalid product ID") ErrReceiptValidationFailed = errors.New("receipt validation failed") ) // AppleIAPClient handles Apple App Store Server API validation type AppleIAPClient struct { keyID string issuerID string bundleID string privateKey *ecdsa.PrivateKey sandbox bool } // GoogleIAPClient handles Google Play Developer API validation type GoogleIAPClient struct { service *androidpublisher.Service packageName string } // AppleTransactionInfo represents decoded transaction info from Apple type AppleTransactionInfo struct { TransactionID string `json:"transactionId"` OriginalTransactionID string `json:"originalTransactionId"` ProductID string `json:"productId"` PurchaseDate int64 `json:"purchaseDate"` ExpiresDate int64 `json:"expiresDate"` Type string `json:"type"` // "Auto-Renewable Subscription" InAppOwnershipType string `json:"inAppOwnershipType"` SignedDate int64 `json:"signedDate"` Environment string `json:"environment"` // "Sandbox" or "Production" BundleID string `json:"bundleId"` RevocationDate *int64 `json:"revocationDate,omitempty"` RevocationReason *int `json:"revocationReason,omitempty"` } // AppleValidationResult contains the result of Apple receipt validation type AppleValidationResult struct { Valid bool TransactionID string ProductID string ExpiresAt time.Time IsTrialPeriod bool AutoRenewEnabled bool Environment string } // GoogleValidationResult contains the result of Google token validation type GoogleValidationResult struct { Valid bool OrderID string ProductID string ExpiresAt time.Time AutoRenewing bool PaymentState int64 CancelReason int64 AcknowledgedState bool } // NewAppleIAPClient creates a new Apple IAP validation client func NewAppleIAPClient(cfg config.AppleIAPConfig) (*AppleIAPClient, error) { if cfg.KeyPath == "" || cfg.KeyID == "" || cfg.IssuerID == "" || cfg.BundleID == "" { return nil, ErrIAPNotConfigured } // Read the private key keyData, err := os.ReadFile(cfg.KeyPath) if err != nil { return nil, fmt.Errorf("failed to read Apple IAP key: %w", err) } // Parse the PEM-encoded private key block, _ := pem.Decode(keyData) if block == nil { return nil, fmt.Errorf("failed to decode PEM block") } privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey) if !ok { return nil, fmt.Errorf("private key is not ECDSA") } return &AppleIAPClient{ keyID: cfg.KeyID, issuerID: cfg.IssuerID, bundleID: cfg.BundleID, privateKey: ecdsaKey, sandbox: cfg.Sandbox, }, nil } // generateJWT creates a signed JWT for App Store Server API authentication func (c *AppleIAPClient) generateJWT() (string, error) { now := time.Now() claims := jwt.MapClaims{ "iss": c.issuerID, "iat": now.Unix(), "exp": now.Add(60 * time.Minute).Unix(), "aud": "appstoreconnect-v1", "bid": c.bundleID, } token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) token.Header["kid"] = c.keyID return token.SignedString(c.privateKey) } // getBaseURL returns the appropriate App Store Server API URL func (c *AppleIAPClient) getBaseURL() string { if c.sandbox { return "https://api.storekit-sandbox.itunes.apple.com" } return "https://api.storekit.itunes.apple.com" } // ValidateTransaction validates a transaction ID with Apple's servers func (c *AppleIAPClient) ValidateTransaction(ctx context.Context, transactionID string) (*AppleValidationResult, error) { token, err := c.generateJWT() if err != nil { return nil, fmt.Errorf("failed to generate JWT: %w", err) } // Get transaction info url := fmt.Sprintf("%s/inApps/v1/transactions/%s", c.getBaseURL(), transactionID) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to call Apple API: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("Apple API returned status %d: %s", resp.StatusCode, string(body)) } // Parse the response var response struct { SignedTransactionInfo string `json:"signedTransactionInfo"` } if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } // Decode the signed transaction info (it's a JWS) transactionInfo, err := c.decodeSignedTransaction(response.SignedTransactionInfo) if err != nil { return nil, err } // Validate bundle ID if transactionInfo.BundleID != c.bundleID { return nil, ErrInvalidReceipt } // Check if revoked if transactionInfo.RevocationDate != nil { return &AppleValidationResult{ Valid: false, }, ErrSubscriptionCancelled } expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) return &AppleValidationResult{ Valid: true, TransactionID: transactionInfo.TransactionID, ProductID: transactionInfo.ProductID, ExpiresAt: expiresAt, Environment: transactionInfo.Environment, }, nil } // ValidateReceipt validates an App Store receipt (base64-encoded) // This is the modern approach using the /verifyReceipt endpoint compatibility // For new integrations, prefer ValidateTransaction with transaction IDs func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) { token, err := c.generateJWT() if err != nil { return nil, fmt.Errorf("failed to generate JWT: %w", err) } // Decode the receipt to get transaction history // The receiptData from StoreKit 2 is typically a signed transaction // For StoreKit 1, we need to use the legacy verifyReceipt endpoint // Try to decode as a signed transaction first (StoreKit 2) if strings.Contains(receiptData, ".") { // This looks like a JWS - try to decode it directly transactionInfo, err := c.decodeSignedTransaction(receiptData) if err == nil { expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) return &AppleValidationResult{ Valid: true, TransactionID: transactionInfo.TransactionID, ProductID: transactionInfo.ProductID, ExpiresAt: expiresAt, Environment: transactionInfo.Environment, }, nil } } // For legacy receipts, use the subscription status endpoint // First, we need to find the original transaction ID from the receipt // This requires using the App Store Server API's history endpoint url := fmt.Sprintf("%s/inApps/v1/history/%s", c.getBaseURL(), c.bundleID) // Create request body requestBody := struct { ReceiptData string `json:"receipt-data"` }{ ReceiptData: receiptData, } jsonBody, err := json.Marshal(requestBody) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to call Apple API: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { // If history endpoint fails, try legacy validation return c.validateLegacyReceipt(ctx, receiptData) } var historyResponse struct { SignedTransactions []string `json:"signedTransactions"` HasMore bool `json:"hasMore"` } if err := json.Unmarshal(body, &historyResponse); err != nil { return nil, fmt.Errorf("failed to parse history response: %w", err) } if len(historyResponse.SignedTransactions) == 0 { return nil, ErrInvalidReceipt } // Get the most recent transaction latestTransaction := historyResponse.SignedTransactions[len(historyResponse.SignedTransactions)-1] transactionInfo, err := c.decodeSignedTransaction(latestTransaction) if err != nil { return nil, err } expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) return &AppleValidationResult{ Valid: true, TransactionID: transactionInfo.TransactionID, ProductID: transactionInfo.ProductID, ExpiresAt: expiresAt, Environment: transactionInfo.Environment, }, nil } // validateLegacyReceipt uses Apple's legacy verifyReceipt endpoint func (c *AppleIAPClient) validateLegacyReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) { url := "https://buy.itunes.apple.com/verifyReceipt" if c.sandbox { url = "https://sandbox.itunes.apple.com/verifyReceipt" } requestBody := map[string]string{ "receipt-data": receiptData, } jsonBody, err := json.Marshal(requestBody) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to call Apple verifyReceipt: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } var legacyResponse struct { Status int `json:"status"` LatestReceiptInfo []struct { TransactionID string `json:"transaction_id"` OriginalTransactionID string `json:"original_transaction_id"` ProductID string `json:"product_id"` ExpiresDateMs string `json:"expires_date_ms"` IsTrialPeriod string `json:"is_trial_period"` } `json:"latest_receipt_info"` PendingRenewalInfo []struct { AutoRenewStatus string `json:"auto_renew_status"` } `json:"pending_renewal_info"` Environment string `json:"environment"` } if err := json.Unmarshal(body, &legacyResponse); err != nil { return nil, fmt.Errorf("failed to parse legacy response: %w", err) } // Status codes: 0 = valid, 21007 = sandbox receipt on production, 21008 = production receipt on sandbox if legacyResponse.Status == 21007 && !c.sandbox { // Retry with sandbox c.sandbox = true result, err := c.validateLegacyReceipt(ctx, receiptData) c.sandbox = false return result, err } if legacyResponse.Status != 0 { return nil, fmt.Errorf("%w: status code %d", ErrReceiptValidationFailed, legacyResponse.Status) } if len(legacyResponse.LatestReceiptInfo) == 0 { return nil, ErrInvalidReceipt } // Find the latest non-expired subscription var latestReceipt = legacyResponse.LatestReceiptInfo[len(legacyResponse.LatestReceiptInfo)-1] var expiresMs int64 fmt.Sscanf(latestReceipt.ExpiresDateMs, "%d", &expiresMs) expiresAt := time.Unix(expiresMs/1000, 0) autoRenew := false if len(legacyResponse.PendingRenewalInfo) > 0 { autoRenew = legacyResponse.PendingRenewalInfo[0].AutoRenewStatus == "1" } return &AppleValidationResult{ Valid: true, TransactionID: latestReceipt.TransactionID, ProductID: latestReceipt.ProductID, ExpiresAt: expiresAt, IsTrialPeriod: latestReceipt.IsTrialPeriod == "true", AutoRenewEnabled: autoRenew, Environment: legacyResponse.Environment, }, nil } // decodeSignedTransaction decodes a JWS signed transaction from Apple func (c *AppleIAPClient) decodeSignedTransaction(signedTransaction string) (*AppleTransactionInfo, error) { // The signed transaction is a JWS (JSON Web Signature) // Format: header.payload.signature parts := strings.Split(signedTransaction, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWS format") } // Decode the payload (base64url encoded) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode payload: %w", err) } var transactionInfo AppleTransactionInfo if err := json.Unmarshal(payload, &transactionInfo); err != nil { return nil, fmt.Errorf("failed to parse transaction info: %w", err) } return &transactionInfo, nil } // NewGoogleIAPClient creates a new Google IAP validation client func NewGoogleIAPClient(ctx context.Context, cfg config.GoogleIAPConfig) (*GoogleIAPClient, error) { if cfg.ServiceAccountPath == "" || cfg.PackageName == "" { return nil, ErrIAPNotConfigured } // Read the service account JSON serviceAccountJSON, err := os.ReadFile(cfg.ServiceAccountPath) if err != nil { return nil, fmt.Errorf("failed to read service account file: %w", err) } // Create credentials creds, err := google.CredentialsFromJSON(ctx, serviceAccountJSON, androidpublisher.AndroidpublisherScope) if err != nil { return nil, fmt.Errorf("failed to create credentials: %w", err) } // Create the Android Publisher service service, err := androidpublisher.NewService(ctx, option.WithCredentials(creds)) if err != nil { return nil, fmt.Errorf("failed to create Android Publisher service: %w", err) } return &GoogleIAPClient{ service: service, packageName: cfg.PackageName, }, nil } // ValidateSubscription validates a Google Play subscription purchase token func (c *GoogleIAPClient) ValidateSubscription(ctx context.Context, subscriptionID, purchaseToken string) (*GoogleValidationResult, error) { // Call Google Play Developer API to verify the subscription subscription, err := c.service.Purchases.Subscriptions.Get( c.packageName, subscriptionID, purchaseToken, ).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("failed to validate subscription: %w", err) } // ExpiryTimeMillis is in milliseconds expiresAt := time.Unix(subscription.ExpiryTimeMillis/1000, 0) // Dereference pointer values safely var paymentState int64 if subscription.PaymentState != nil { paymentState = *subscription.PaymentState } result := &GoogleValidationResult{ Valid: true, OrderID: subscription.OrderId, ProductID: subscriptionID, ExpiresAt: expiresAt, AutoRenewing: subscription.AutoRenewing, PaymentState: paymentState, CancelReason: subscription.CancelReason, AcknowledgedState: subscription.AcknowledgementState == 1, } // Check if subscription is valid now := time.Now() if expiresAt.Before(now) { return result, ErrSubscriptionExpired } // Cancel reason: 0 = user cancelled, 1 = system cancelled if subscription.CancelReason > 0 { result.Valid = false return result, ErrSubscriptionCancelled } return result, nil } // ValidatePurchaseToken validates a purchase token, attempting to determine the subscription ID // This is a convenience method when the client only sends the token func (c *GoogleIAPClient) ValidatePurchaseToken(ctx context.Context, purchaseToken string, knownSubscriptionIDs []string) (*GoogleValidationResult, error) { // Try each known subscription ID until one works for _, subID := range knownSubscriptionIDs { result, err := c.ValidateSubscription(ctx, subID, purchaseToken) if err == nil { return result, nil } // If it's not a "not found" error, return the error if !strings.Contains(err.Error(), "notFound") && !strings.Contains(err.Error(), "404") { continue } } return nil, ErrInvalidPurchaseToken } // AcknowledgeSubscription acknowledges a subscription purchase // This must be called within 3 days of purchase or the subscription is refunded func (c *GoogleIAPClient) AcknowledgeSubscription(ctx context.Context, subscriptionID, purchaseToken string) error { err := c.service.Purchases.Subscriptions.Acknowledge( c.packageName, subscriptionID, purchaseToken, &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{}, ).Context(ctx).Do() if err != nil { return fmt.Errorf("failed to acknowledge subscription: %w", err) } return nil }