package handlers import ( "crypto/ecdsa" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" ) // SubscriptionWebhookHandler handles subscription webhook callbacks type SubscriptionWebhookHandler struct { subscriptionRepo *repositories.SubscriptionRepository userRepo *repositories.UserRepository webhookEventRepo *repositories.WebhookEventRepository appleRootCerts []*x509.Certificate stripeService *services.StripeService enabled bool } // NewSubscriptionWebhookHandler creates a new webhook handler func NewSubscriptionWebhookHandler( subscriptionRepo *repositories.SubscriptionRepository, userRepo *repositories.UserRepository, webhookEventRepo *repositories.WebhookEventRepository, enabled bool, ) *SubscriptionWebhookHandler { return &SubscriptionWebhookHandler{ subscriptionRepo: subscriptionRepo, userRepo: userRepo, webhookEventRepo: webhookEventRepo, enabled: enabled, } } // SetStripeService sets the Stripe service for webhook handling func (h *SubscriptionWebhookHandler) SetStripeService(stripeService *services.StripeService) { h.stripeService = stripeService } // ==================== // Apple App Store Server Notifications v2 // ==================== // AppleNotificationPayload represents the outer signed payload from Apple type AppleNotificationPayload struct { SignedPayload string `json:"signedPayload"` } // AppleNotificationData represents the decoded notification data type AppleNotificationData struct { NotificationType string `json:"notificationType"` Subtype string `json:"subtype"` NotificationUUID string `json:"notificationUUID"` Data AppleNotificationDataInner `json:"data"` Version string `json:"version"` SignedDate int64 `json:"signedDate"` } // AppleNotificationDataInner contains the transaction details type AppleNotificationDataInner struct { AppAppleID int64 `json:"appAppleId"` BundleID string `json:"bundleId"` BundleVersion string `json:"bundleVersion"` Environment string `json:"environment"` SignedTransactionInfo string `json:"signedTransactionInfo"` SignedRenewalInfo string `json:"signedRenewalInfo"` } // AppleTransactionInfo represents decoded transaction info 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"` AppAccountToken string `json:"appAccountToken"` // Your user ID if set during purchase BundleID string `json:"bundleId"` Environment string `json:"environment"` RevocationDate *int64 `json:"revocationDate,omitempty"` RevocationReason *int `json:"revocationReason,omitempty"` } // AppleRenewalInfo represents subscription renewal info type AppleRenewalInfo struct { AutoRenewProductID string `json:"autoRenewProductId"` AutoRenewStatus int `json:"autoRenewStatus"` // 1 = will renew, 0 = turned off ExpirationIntent int `json:"expirationIntent"` IsInBillingRetry bool `json:"isInBillingRetryPeriod"` } // HandleAppleWebhook handles POST /api/subscription/webhook/apple/ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error { if !h.enabled { log.Info().Msg("Apple Webhook: webhooks disabled by feature flag") return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"}) } body, err := io.ReadAll(c.Request().Body) if err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to read body") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"}) } var payload AppleNotificationPayload if err := json.Unmarshal(body, &payload); err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to parse payload") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid payload"}) } // Decode and verify the signed payload (JWS) notification, err := h.decodeAppleSignedPayload(payload.SignedPayload) if err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to decode signed payload") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signed payload"}) } log.Info().Str("type", notification.NotificationType).Str("subtype", notification.Subtype).Str("bundle", notification.Data.BundleID).Msg("Apple Webhook: Received notification") // Dedup check using notificationUUID if notification.NotificationUUID != "" { alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID) if err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup") // Continue processing on dedup check failure (fail-open) } else if alreadyProcessed { log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping") return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) } } // Verify bundle ID matches our app cfg := config.Get() if cfg != nil && cfg.AppleIAP.BundleID != "" { if notification.Data.BundleID != cfg.AppleIAP.BundleID { log.Warn().Str("got", notification.Data.BundleID).Str("expected", cfg.AppleIAP.BundleID).Msg("Apple Webhook: Bundle ID mismatch") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "bundle ID mismatch"}) } } // Decode transaction info transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo) if err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to decode transaction") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid transaction info"}) } // Decode renewal info if present var renewalInfo *AppleRenewalInfo if notification.Data.SignedRenewalInfo != "" { renewalInfo, _ = h.decodeAppleRenewalInfo(notification.Data.SignedRenewalInfo) } // Process the notification if err := h.processAppleNotification(notification, transactionInfo, renewalInfo); err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to process notification") // Still return 200 to prevent Apple from retrying } // Record processed event for dedup if notification.NotificationUUID != "" { if err := h.webhookEventRepo.RecordEvent("apple", notification.NotificationUUID, notification.NotificationType, ""); err != nil { log.Error().Err(err).Msg("Apple Webhook: Failed to record event") } } // Always return 200 OK to acknowledge receipt return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"}) } // decodeAppleSignedPayload verifies and decodes an Apple JWS payload. // The JWS signature is verified before the payload is trusted. func (h *SubscriptionWebhookHandler) decodeAppleSignedPayload(signedPayload string) (*AppleNotificationData, error) { // JWS format: header.payload.signature parts := strings.Split(signedPayload, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWS format") } // Verify the JWS signature before trusting the payload. if err := h.VerifyAppleSignature(signedPayload); err != nil { return nil, fmt.Errorf("Apple JWS signature verification failed: %w", err) } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode payload: %w", err) } var notification AppleNotificationData if err := json.Unmarshal(payload, ¬ification); err != nil { return nil, fmt.Errorf("failed to parse notification: %w", err) } return ¬ification, nil } // decodeAppleTransaction decodes a signed transaction info JWS func (h *SubscriptionWebhookHandler) decodeAppleTransaction(signedTransaction string) (*AppleTransactionInfo, error) { parts := strings.Split(signedTransaction, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWS format") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode payload: %w", err) } var info AppleTransactionInfo if err := json.Unmarshal(payload, &info); err != nil { return nil, fmt.Errorf("failed to parse transaction info: %w", err) } return &info, nil } // decodeAppleRenewalInfo decodes signed renewal info JWS func (h *SubscriptionWebhookHandler) decodeAppleRenewalInfo(signedRenewal string) (*AppleRenewalInfo, error) { parts := strings.Split(signedRenewal, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWS format") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode payload: %w", err) } var info AppleRenewalInfo if err := json.Unmarshal(payload, &info); err != nil { return nil, fmt.Errorf("failed to parse renewal info: %w", err) } return &info, nil } // processAppleNotification handles the business logic for Apple notifications func (h *SubscriptionWebhookHandler) processAppleNotification( notification *AppleNotificationData, transaction *AppleTransactionInfo, renewal *AppleRenewalInfo, ) error { // Find user by stored receipt data (original transaction ID) user, err := h.findUserByAppleTransaction(transaction.OriginalTransactionID) if err != nil { log.Warn().Err(err).Str("transaction_id", transaction.OriginalTransactionID).Msg("Apple Webhook: Could not find user for transaction") // Not an error - might be a transaction we don't track return nil } log.Info().Str("type", notification.NotificationType).Uint("user_id", user.ID).Str("product", transaction.ProductID).Msg("Apple Webhook: Processing notification") switch notification.NotificationType { case "SUBSCRIBED": // New subscription or resubscription return h.handleAppleSubscribed(user.ID, transaction, renewal) case "DID_RENEW": // Subscription successfully renewed return h.handleAppleRenewed(user.ID, transaction, renewal) case "DID_CHANGE_RENEWAL_STATUS": // User turned auto-renew on/off return h.handleAppleRenewalStatusChange(user.ID, transaction, renewal) case "DID_FAIL_TO_RENEW": // Billing issue - subscription may still be in grace period return h.handleAppleFailedToRenew(user.ID, transaction, renewal) case "EXPIRED": // Subscription expired return h.handleAppleExpired(user.ID, transaction) case "REFUND": // User got a refund return h.handleAppleRefund(user.ID, transaction) case "REVOKE": // Family sharing revoked or refund return h.handleAppleRevoke(user.ID, transaction) case "GRACE_PERIOD_EXPIRED": // Grace period ended without successful billing return h.handleAppleGracePeriodExpired(user.ID, transaction) default: log.Warn().Str("type", notification.NotificationType).Msg("Apple Webhook: Unhandled notification type") } return nil } func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) { // Look up user subscription by stored receipt data subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID) if err != nil { return nil, err } user, err := h.userRepo.FindByID(subscription.UserID) if err != nil { return nil, err } return user, nil } func (h *SubscriptionWebhookHandler) handleAppleSubscribed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { expiresAt := time.Unix(tx.ExpiresDate/1000, 0) autoRenew := renewal != nil && renewal.AutoRenewStatus == 1 if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { return err } if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil { return err } log.Info().Uint("user_id", userID).Time("expires", expiresAt).Bool("auto_renew", autoRenew).Msg("Apple Webhook: User subscribed") return nil } func (h *SubscriptionWebhookHandler) handleAppleRenewed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { expiresAt := time.Unix(tx.ExpiresDate/1000, 0) if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { return err } log.Info().Uint("user_id", userID).Time("expires", expiresAt).Msg("Apple Webhook: User renewed") return nil } func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { if renewal == nil { return nil } autoRenew := renewal.AutoRenewStatus == 1 if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil { return err } if !autoRenew { // User turned off auto-renew (will cancel at end of period) now := time.Now().UTC() if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil { return err } log.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned off auto-renew, will expire at end of period") } else { // User turned auto-renew back on if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil { return err } log.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned auto-renew back on") } return nil } func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { // Subscription is in billing retry or grace period log.Warn().Uint("user_id", userID).Msg("Apple Webhook: User failed to renew, may be in grace period") // Don't downgrade yet - Apple may retry billing return nil } func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTransactionInfo) error { if err := h.safeDowngradeToFree(userID, "Apple expired"); err != nil { return err } return nil } func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTransactionInfo) error { if err := h.safeDowngradeToFree(userID, "Apple refund"); err != nil { return err } return nil } func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTransactionInfo) error { if err := h.safeDowngradeToFree(userID, "Apple revoke"); err != nil { return err } return nil } func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint, tx *AppleTransactionInfo) error { if err := h.safeDowngradeToFree(userID, "Apple grace period expired"); err != nil { return err } return nil } // ==================== // Google Real-time Developer Notifications // ==================== // GoogleNotification represents a Google Pub/Sub push message type GoogleNotification struct { Message GooglePubSubMessage `json:"message"` Subscription string `json:"subscription"` } // GooglePubSubMessage represents the Pub/Sub message wrapper type GooglePubSubMessage struct { Data string `json:"data"` // Base64 encoded MessageID string `json:"messageId"` PublishTime string `json:"publishTime"` Attributes map[string]string `json:"attributes"` } // GoogleDeveloperNotification represents the decoded notification type GoogleDeveloperNotification struct { Version string `json:"version"` PackageName string `json:"packageName"` EventTimeMillis string `json:"eventTimeMillis"` SubscriptionNotification *GoogleSubscriptionNotification `json:"subscriptionNotification"` OneTimeProductNotification *GoogleOneTimeNotification `json:"oneTimeProductNotification"` TestNotification *GoogleTestNotification `json:"testNotification"` } // GoogleSubscriptionNotification represents subscription-specific data type GoogleSubscriptionNotification struct { Version string `json:"version"` NotificationType int `json:"notificationType"` PurchaseToken string `json:"purchaseToken"` SubscriptionID string `json:"subscriptionId"` } // GoogleOneTimeNotification represents one-time purchase data type GoogleOneTimeNotification struct { Version string `json:"version"` NotificationType int `json:"notificationType"` PurchaseToken string `json:"purchaseToken"` SKU string `json:"sku"` } // GoogleTestNotification represents a test notification type GoogleTestNotification struct { Version string `json:"version"` } // Google subscription notification types const ( GoogleSubRecovered = 1 // Subscription recovered from account hold GoogleSubRenewed = 2 // Active subscription renewed GoogleSubCanceled = 3 // Subscription was cancelled (voluntary or involuntary) GoogleSubPurchased = 4 // New subscription purchased GoogleSubOnHold = 5 // Subscription entered account hold GoogleSubInGracePeriod = 6 // Subscription entered grace period GoogleSubRestarted = 7 // User reactivated subscription GoogleSubPriceChangeConfirmed = 8 // Price change confirmed by user GoogleSubDeferred = 9 // Subscription deferred GoogleSubPaused = 10 // Subscription paused GoogleSubPauseScheduleChanged = 11 // Pause schedule changed GoogleSubRevoked = 12 // Subscription revoked GoogleSubExpired = 13 // Subscription expired ) // HandleGoogleWebhook handles POST /api/subscription/webhook/google/ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error { if !h.enabled { log.Info().Msg("Google Webhook: webhooks disabled by feature flag") return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"}) } body, err := io.ReadAll(c.Request().Body) if err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to read body") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"}) } var notification GoogleNotification if err := json.Unmarshal(body, ¬ification); err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to parse notification") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid notification"}) } // Decode the base64 data data, err := base64.StdEncoding.DecodeString(notification.Message.Data) if err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to decode message data") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid message data"}) } var devNotification GoogleDeveloperNotification if err := json.Unmarshal(data, &devNotification); err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to parse developer notification") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"}) } // Dedup check using messageId messageID := notification.Message.MessageID if messageID != "" { alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID) if err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to check dedup") // Continue processing on dedup check failure (fail-open) } else if alreadyProcessed { log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping") return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) } } // Handle test notification if devNotification.TestNotification != nil { log.Info().Msg("Google Webhook: Received test notification") return c.JSON(http.StatusOK, map[string]interface{}{"status": "test received"}) } // Verify package name cfg := config.Get() if cfg != nil && cfg.GoogleIAP.PackageName != "" { if devNotification.PackageName != cfg.GoogleIAP.PackageName { log.Warn().Str("got", devNotification.PackageName).Str("expected", cfg.GoogleIAP.PackageName).Msg("Google Webhook: Package name mismatch") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "package name mismatch"}) } } // Process subscription notification if devNotification.SubscriptionNotification != nil { if err := h.processGoogleSubscriptionNotification(devNotification.SubscriptionNotification); err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to process notification") // Still return 200 to acknowledge } } // Record processed event for dedup if messageID != "" { eventType := "unknown" if devNotification.SubscriptionNotification != nil { eventType = fmt.Sprintf("subscription_%d", devNotification.SubscriptionNotification.NotificationType) } if err := h.webhookEventRepo.RecordEvent("google", messageID, eventType, ""); err != nil { log.Error().Err(err).Msg("Google Webhook: Failed to record event") } } // Acknowledge the message return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"}) } // processGoogleSubscriptionNotification handles Google subscription events func (h *SubscriptionWebhookHandler) processGoogleSubscriptionNotification(notification *GoogleSubscriptionNotification) error { // Find user by purchase token user, err := h.findUserByGoogleToken(notification.PurchaseToken) if err != nil { log.Warn().Err(err).Msg("Google Webhook: Could not find user for token") return nil // Not an error - might be unknown token } log.Info().Int("type", notification.NotificationType).Uint("user_id", user.ID).Str("subscription", notification.SubscriptionID).Msg("Google Webhook: Processing notification") switch notification.NotificationType { case GoogleSubPurchased: return h.handleGooglePurchased(user.ID, notification) case GoogleSubRenewed: return h.handleGoogleRenewed(user.ID, notification) case GoogleSubRecovered: return h.handleGoogleRecovered(user.ID, notification) case GoogleSubCanceled: return h.handleGoogleCanceled(user.ID, notification) case GoogleSubOnHold: return h.handleGoogleOnHold(user.ID, notification) case GoogleSubInGracePeriod: return h.handleGoogleGracePeriod(user.ID, notification) case GoogleSubRestarted: return h.handleGoogleRestarted(user.ID, notification) case GoogleSubRevoked: return h.handleGoogleRevoked(user.ID, notification) case GoogleSubExpired: return h.handleGoogleExpired(user.ID, notification) case GoogleSubPaused: return h.handleGooglePaused(user.ID, notification) default: log.Warn().Int("type", notification.NotificationType).Msg("Google Webhook: Unhandled notification type") } return nil } func (h *SubscriptionWebhookHandler) findUserByGoogleToken(purchaseToken string) (*models.User, error) { subscription, err := h.subscriptionRepo.FindByGoogleToken(purchaseToken) if err != nil { return nil, err } user, err := h.userRepo.FindByID(subscription.UserID) if err != nil { return nil, err } return user, nil } func (h *SubscriptionWebhookHandler) handleGooglePurchased(userID uint, notification *GoogleSubscriptionNotification) error { // New subscription - we should have already processed this via the client // This is a backup notification log.Info().Uint("user_id", userID).Str("subscription", notification.SubscriptionID).Msg("Google Webhook: User purchased subscription") return nil } func (h *SubscriptionWebhookHandler) handleGoogleRenewed(userID uint, notification *GoogleSubscriptionNotification) error { // Need to query Google API for new expiry date // For now, extend by typical period (1 month for monthly, 1 year for yearly) var extension time.Duration if strings.Contains(notification.SubscriptionID, "monthly") { extension = 30 * 24 * time.Hour } else { extension = 365 * 24 * time.Hour } newExpiry := time.Now().UTC().Add(extension) if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { return err } log.Info().Uint("user_id", userID).Time("expires", newExpiry).Msg("Google Webhook: User renewed") return nil } func (h *SubscriptionWebhookHandler) handleGoogleRecovered(userID uint, notification *GoogleSubscriptionNotification) error { // Subscription recovered from account hold - reactivate newExpiry := time.Now().UTC().AddDate(0, 1, 0) // 1 month from now if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { return err } log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription recovered") return nil } func (h *SubscriptionWebhookHandler) handleGoogleCanceled(userID uint, notification *GoogleSubscriptionNotification) error { // User canceled - will expire at end of period now := time.Now().UTC() if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil { return err } if err := h.subscriptionRepo.SetAutoRenew(userID, false); err != nil { return err } log.Info().Uint("user_id", userID).Msg("Google Webhook: User canceled, will expire at end of period") return nil } func (h *SubscriptionWebhookHandler) handleGoogleOnHold(userID uint, notification *GoogleSubscriptionNotification) error { // Account hold - payment issue, may recover log.Warn().Uint("user_id", userID).Msg("Google Webhook: User subscription on hold") return nil } func (h *SubscriptionWebhookHandler) handleGoogleGracePeriod(userID uint, notification *GoogleSubscriptionNotification) error { // In grace period - user still has access but billing failed log.Warn().Uint("user_id", userID).Msg("Google Webhook: User in grace period") return nil } func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notification *GoogleSubscriptionNotification) error { // User restarted subscription newExpiry := time.Now().UTC().AddDate(0, 1, 0) if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { return err } if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil { return err } if err := h.subscriptionRepo.SetAutoRenew(userID, true); err != nil { return err } log.Info().Uint("user_id", userID).Msg("Google Webhook: User restarted subscription") return nil } func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notification *GoogleSubscriptionNotification) error { if err := h.safeDowngradeToFree(userID, "Google revoke"); err != nil { return err } return nil } func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notification *GoogleSubscriptionNotification) error { if err := h.safeDowngradeToFree(userID, "Google expired"); err != nil { return err } return nil } func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notification *GoogleSubscriptionNotification) error { // Subscription paused by user log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription paused") return nil } // ==================== // Multi-Source Downgrade Safety // ==================== // safeDowngradeToFree checks if the user has active subscriptions from other sources // before downgrading to free. If another source is still active, skip the downgrade. func (h *SubscriptionWebhookHandler) safeDowngradeToFree(userID uint, reason string) error { sub, err := h.subscriptionRepo.FindByUserID(userID) if err != nil { log.Warn().Err(err).Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Could not find subscription for multi-source check, proceeding with downgrade") return h.subscriptionRepo.DowngradeToFree(userID) } // Check if Stripe subscription is still active if sub.HasStripeSubscription() && sub.Platform != models.PlatformStripe { log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Stripe subscription") return nil } // Check if Apple subscription is still active (for Google/Stripe webhooks) if sub.HasAppleSubscription() && sub.Platform != models.PlatformIOS { log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Apple subscription") return nil } // Check if Google subscription is still active (for Apple/Stripe webhooks) if sub.HasGoogleSubscription() && sub.Platform != models.PlatformAndroid { log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Google subscription") return nil } // Check if trial is still active if sub.IsTrialActive() { log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active trial") return nil } if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { return err } log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: User downgraded to free (no other active sources)") return nil } // ==================== // Stripe Webhooks // ==================== // HandleStripeWebhook handles POST /api/subscription/webhook/stripe/ func (h *SubscriptionWebhookHandler) HandleStripeWebhook(c echo.Context) error { if !h.enabled { log.Info().Msg("Stripe Webhook: webhooks disabled by feature flag") return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"}) } if h.stripeService == nil { log.Warn().Msg("Stripe Webhook: Stripe service not configured") return c.JSON(http.StatusOK, map[string]interface{}{"status": "not_configured"}) } body, err := io.ReadAll(c.Request().Body) if err != nil { log.Error().Err(err).Msg("Stripe Webhook: Failed to read body") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"}) } signature := c.Request().Header.Get("Stripe-Signature") if signature == "" { log.Warn().Msg("Stripe Webhook: Missing Stripe-Signature header") return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "missing signature"}) } if err := h.stripeService.HandleWebhookEvent(body, signature); err != nil { log.Error().Err(err).Msg("Stripe Webhook: Failed to process webhook") // Still return 200 to prevent Stripe from retrying on business logic errors // Only return error for signature verification failures if strings.Contains(err.Error(), "signature") { return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signature"}) } } return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"}) } // ==================== // Signature Verification (Optional but Recommended) // ==================== // VerifyAppleSignature verifies the JWS signature using Apple's root certificate. // If root certificates are not loaded, verification fails (deny by default). func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string) error { // Deny by default when root certificates are not loaded. if h.appleRootCerts == nil { return fmt.Errorf("Apple root certificates not configured: cannot verify JWS signature") } // Build a certificate pool from the loaded Apple root certificates rootPool := x509.NewCertPool() for _, cert := range h.appleRootCerts { rootPool.AddCert(cert) } // Parse the JWS token and verify the signature using the x5c certificate chain token, err := jwt.Parse(signedPayload, func(token *jwt.Token) (interface{}, error) { // Get the x5c header (certificate chain) x5c, ok := token.Header["x5c"].([]interface{}) if !ok || len(x5c) == 0 { return nil, fmt.Errorf("missing x5c header") } // Decode the leaf certificate certData, err := base64.StdEncoding.DecodeString(x5c[0].(string)) if err != nil { return nil, fmt.Errorf("failed to decode certificate: %w", err) } leafCert, err := x509.ParseCertificate(certData) if err != nil { return nil, fmt.Errorf("failed to parse certificate: %w", err) } // Build intermediate pool from remaining x5c entries intermediatePool := x509.NewCertPool() for i := 1; i < len(x5c); i++ { intermData, err := base64.StdEncoding.DecodeString(x5c[i].(string)) if err != nil { return nil, fmt.Errorf("failed to decode intermediate certificate: %w", err) } intermCert, err := x509.ParseCertificate(intermData) if err != nil { return nil, fmt.Errorf("failed to parse intermediate certificate: %w", err) } intermediatePool.AddCert(intermCert) } // Verify the certificate chain against Apple's root certificates opts := x509.VerifyOptions{ Roots: rootPool, Intermediates: intermediatePool, } if _, err := leafCert.Verify(opts); err != nil { return nil, fmt.Errorf("certificate chain verification failed: %w", err) } ecdsaKey, ok := leafCert.PublicKey.(*ecdsa.PublicKey) if !ok { return nil, fmt.Errorf("leaf certificate public key is not ECDSA") } return ecdsaKey, nil }) if err != nil { return fmt.Errorf("failed to verify signature: %w", err) } if !token.Valid { return fmt.Errorf("invalid token") } return nil } // VerifyGooglePubSubToken verifies the Pub/Sub push authentication token. // Returns false (deny) when the Authorization header is missing or the token // cannot be validated. This prevents unauthenticated callers from injecting // webhook events. func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool { authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { log.Warn().Msg("Google Webhook: missing Authorization header") return false } // Expect "Bearer " format if !strings.HasPrefix(authHeader, "Bearer ") { log.Warn().Msg("Google Webhook: Authorization header is not Bearer token") return false } bearerToken := strings.TrimPrefix(authHeader, "Bearer ") if bearerToken == "" { log.Warn().Msg("Google Webhook: empty Bearer token") return false } // Parse the token as a JWT. Google Pub/Sub push tokens are signed JWTs // issued by accounts.google.com. We verify the claims to ensure the // token was intended for our service. token, _, err := jwt.NewParser().ParseUnverified(bearerToken, jwt.MapClaims{}) if err != nil { log.Warn().Err(err).Msg("Google Webhook: failed to parse Bearer token") return false } claims, ok := token.Claims.(jwt.MapClaims) if !ok { log.Warn().Msg("Google Webhook: invalid token claims") return false } // Verify issuer is Google issuer, _ := claims.GetIssuer() if issuer != "accounts.google.com" && issuer != "https://accounts.google.com" { log.Warn().Str("issuer", issuer).Msg("Google Webhook: unexpected issuer") return false } // Verify the email claim matches a Google service account email, _ := claims["email"].(string) if email == "" || !strings.HasSuffix(email, ".gserviceaccount.com") { log.Warn().Str("email", email).Msg("Google Webhook: token email is not a Google service account") return false } return true } // Helper function to load Apple root certificates from file func loadAppleRootCertificates(certPath string) ([]*x509.Certificate, error) { data, err := os.ReadFile(certPath) if err != nil { return nil, err } var certs []*x509.Certificate for { block, rest := pem.Decode(data) if block == nil { break } if block.Type == "CERTIFICATE" { cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, err } certs = append(certs, cert) } data = rest } return certs, nil }