Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
564 lines
18 KiB
Go
564 lines
18 KiB
Go
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/honeydue-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.
|
|
// It delegates to validateLegacyReceiptWithSandbox using the client's
|
|
// configured sandbox setting. This avoids mutating the struct field
|
|
// during the sandbox-retry flow, which caused a data race when
|
|
// multiple goroutines shared the same AppleIAPClient.
|
|
func (c *AppleIAPClient) validateLegacyReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) {
|
|
return c.validateLegacyReceiptWithSandbox(ctx, receiptData, c.sandbox)
|
|
}
|
|
|
|
// validateLegacyReceiptWithSandbox performs legacy receipt validation against
|
|
// the specified environment. The sandbox parameter is passed by value (not
|
|
// stored on the struct) so this function is safe for concurrent use.
|
|
func (c *AppleIAPClient) validateLegacyReceiptWithSandbox(ctx context.Context, receiptData string, useSandbox bool) (*AppleValidationResult, error) {
|
|
url := "https://buy.itunes.apple.com/verifyReceipt"
|
|
if useSandbox {
|
|
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 && !useSandbox {
|
|
// Retry with sandbox -- pass sandbox=true as a parameter instead of
|
|
// mutating c.sandbox, which avoids a data race.
|
|
return c.validateLegacyReceiptWithSandbox(ctx, receiptData, true)
|
|
}
|
|
|
|
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
|
|
}
|