Harden API security: input validation, safe auth extraction, new tests, and deploy config

Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -8,14 +8,14 @@ import (
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/labstack/echo/v4"
"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"
@@ -101,40 +101,39 @@ type AppleRenewalInfo struct {
// HandleAppleWebhook handles POST /api/subscription/webhook/apple/
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
if !h.enabled {
log.Printf("Apple Webhook: webhooks disabled by feature flag")
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.Printf("Apple Webhook: Failed to read body: %v", err)
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.Printf("Apple Webhook: Failed to parse payload: %v", err)
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.Printf("Apple Webhook: Failed to decode signed payload: %v", err)
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.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s",
notification.NotificationType, notification.Subtype, notification.Data.BundleID)
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.Printf("Apple Webhook: Failed to check dedup: %v", err)
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup")
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
log.Printf("Apple Webhook: Duplicate event %s, skipping", notification.NotificationUUID)
log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
}
}
@@ -143,8 +142,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
cfg := config.Get()
if cfg != nil && cfg.AppleIAP.BundleID != "" {
if notification.Data.BundleID != cfg.AppleIAP.BundleID {
log.Printf("Apple Webhook: Bundle ID mismatch: got %s, expected %s",
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"})
}
}
@@ -152,7 +150,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
// Decode transaction info
transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo)
if err != nil {
log.Printf("Apple Webhook: Failed to decode transaction: %v", err)
log.Error().Err(err).Msg("Apple Webhook: Failed to decode transaction")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid transaction info"})
}
@@ -164,14 +162,14 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
// Process the notification
if err := h.processAppleNotification(notification, transactionInfo, renewalInfo); err != nil {
log.Printf("Apple Webhook: Failed to process notification: %v", err)
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.Printf("Apple Webhook: Failed to record event: %v", err)
log.Error().Err(err).Msg("Apple Webhook: Failed to record event")
}
}
@@ -179,7 +177,8 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// decodeAppleSignedPayload decodes and verifies an Apple JWS payload
// 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, ".")
@@ -187,8 +186,11 @@ func (h *SubscriptionWebhookHandler) decodeAppleSignedPayload(signedPayload stri
return nil, fmt.Errorf("invalid JWS format")
}
// Decode payload (we're trusting Apple's signature for now)
// In production, you should verify the signature using Apple's root certificate
// 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)
@@ -251,14 +253,12 @@ func (h *SubscriptionWebhookHandler) processAppleNotification(
// Find user by stored receipt data (original transaction ID)
user, err := h.findUserByAppleTransaction(transaction.OriginalTransactionID)
if err != nil {
log.Printf("Apple Webhook: Could not find user for transaction %s: %v",
transaction.OriginalTransactionID, err)
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.Printf("Apple Webhook: Processing %s for user %d (product: %s)",
notification.NotificationType, user.ID, transaction.ProductID)
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":
@@ -294,7 +294,7 @@ func (h *SubscriptionWebhookHandler) processAppleNotification(
return h.handleAppleGracePeriodExpired(user.ID, transaction)
default:
log.Printf("Apple Webhook: Unhandled notification type: %s", notification.NotificationType)
log.Warn().Str("type", notification.NotificationType).Msg("Apple Webhook: Unhandled notification type")
}
return nil
@@ -326,7 +326,7 @@ func (h *SubscriptionWebhookHandler) handleAppleSubscribed(userID uint, tx *Appl
return err
}
log.Printf("Apple Webhook: User %d subscribed, expires %v, autoRenew=%v", userID, expiresAt, autoRenew)
log.Info().Uint("user_id", userID).Time("expires", expiresAt).Bool("auto_renew", autoRenew).Msg("Apple Webhook: User subscribed")
return nil
}
@@ -337,7 +337,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRenewed(userID uint, tx *AppleTr
return err
}
log.Printf("Apple Webhook: User %d renewed, new expiry %v", userID, expiresAt)
log.Info().Uint("user_id", userID).Time("expires", expiresAt).Msg("Apple Webhook: User renewed")
return nil
}
@@ -357,13 +357,13 @@ func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint,
if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil {
return err
}
log.Printf("Apple Webhook: User %d turned off auto-renew, will expire at end of period", userID)
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.Printf("Apple Webhook: User %d turned auto-renew back on", userID)
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User turned auto-renew back on")
}
return nil
@@ -371,7 +371,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint,
func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error {
// Subscription is in billing retry or grace period
log.Printf("Apple Webhook: User %d failed to renew, may be in grace period", userID)
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
}
@@ -381,7 +381,7 @@ func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTr
return err
}
log.Printf("Apple Webhook: User %d subscription expired, downgraded to free", userID)
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User subscription expired, downgraded to free")
return nil
}
@@ -390,7 +390,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTra
return err
}
log.Printf("Apple Webhook: User %d got refund, downgraded to free", userID)
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User got refund, downgraded to free")
return nil
}
@@ -399,7 +399,7 @@ func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTra
return err
}
log.Printf("Apple Webhook: User %d subscription revoked, downgraded to free", userID)
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User subscription revoked, downgraded to free")
return nil
}
@@ -408,7 +408,7 @@ func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint,
return err
}
log.Printf("Apple Webhook: User %d grace period expired, downgraded to free", userID)
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User grace period expired, downgraded to free")
return nil
}
@@ -481,32 +481,32 @@ const (
// HandleGoogleWebhook handles POST /api/subscription/webhook/google/
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if !h.enabled {
log.Printf("Google Webhook: webhooks disabled by feature flag")
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.Printf("Google Webhook: Failed to read body: %v", err)
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, &notification); err != nil {
log.Printf("Google Webhook: Failed to parse notification: %v", err)
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.Printf("Google Webhook: Failed to decode message data: %v", err)
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.Printf("Google Webhook: Failed to parse developer notification: %v", err)
log.Error().Err(err).Msg("Google Webhook: Failed to parse developer notification")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"})
}
@@ -515,17 +515,17 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if messageID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
if err != nil {
log.Printf("Google Webhook: Failed to check dedup: %v", err)
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup")
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
log.Printf("Google Webhook: Duplicate event %s, skipping", messageID)
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.Printf("Google Webhook: Received test notification")
log.Info().Msg("Google Webhook: Received test notification")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "test received"})
}
@@ -533,8 +533,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
cfg := config.Get()
if cfg != nil && cfg.GoogleIAP.PackageName != "" {
if devNotification.PackageName != cfg.GoogleIAP.PackageName {
log.Printf("Google Webhook: Package name mismatch: got %s, expected %s",
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"})
}
}
@@ -542,7 +541,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
// Process subscription notification
if devNotification.SubscriptionNotification != nil {
if err := h.processGoogleSubscriptionNotification(devNotification.SubscriptionNotification); err != nil {
log.Printf("Google Webhook: Failed to process notification: %v", err)
log.Error().Err(err).Msg("Google Webhook: Failed to process notification")
// Still return 200 to acknowledge
}
}
@@ -554,7 +553,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
eventType = fmt.Sprintf("subscription_%d", devNotification.SubscriptionNotification.NotificationType)
}
if err := h.webhookEventRepo.RecordEvent("google", messageID, eventType, ""); err != nil {
log.Printf("Google Webhook: Failed to record event: %v", err)
log.Error().Err(err).Msg("Google Webhook: Failed to record event")
}
}
@@ -567,12 +566,11 @@ func (h *SubscriptionWebhookHandler) processGoogleSubscriptionNotification(notif
// Find user by purchase token
user, err := h.findUserByGoogleToken(notification.PurchaseToken)
if err != nil {
log.Printf("Google Webhook: Could not find user for token: %v", err)
log.Warn().Err(err).Msg("Google Webhook: Could not find user for token")
return nil // Not an error - might be unknown token
}
log.Printf("Google Webhook: Processing type %d for user %d (subscription: %s)",
notification.NotificationType, user.ID, notification.SubscriptionID)
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:
@@ -606,7 +604,7 @@ func (h *SubscriptionWebhookHandler) processGoogleSubscriptionNotification(notif
return h.handleGooglePaused(user.ID, notification)
default:
log.Printf("Google Webhook: Unhandled notification type: %d", notification.NotificationType)
log.Warn().Int("type", notification.NotificationType).Msg("Google Webhook: Unhandled notification type")
}
return nil
@@ -629,7 +627,7 @@ func (h *SubscriptionWebhookHandler) findUserByGoogleToken(purchaseToken string)
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.Printf("Google Webhook: User %d purchased subscription %s", userID, notification.SubscriptionID)
log.Info().Uint("user_id", userID).Str("subscription", notification.SubscriptionID).Msg("Google Webhook: User purchased subscription")
return nil
}
@@ -648,7 +646,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRenewed(userID uint, notificati
return err
}
log.Printf("Google Webhook: User %d renewed, extended to %v", userID, newExpiry)
log.Info().Uint("user_id", userID).Time("expires", newExpiry).Msg("Google Webhook: User renewed")
return nil
}
@@ -659,7 +657,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRecovered(userID uint, notifica
return err
}
log.Printf("Google Webhook: User %d subscription recovered", userID)
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription recovered")
return nil
}
@@ -673,19 +671,19 @@ func (h *SubscriptionWebhookHandler) handleGoogleCanceled(userID uint, notificat
return err
}
log.Printf("Google Webhook: User %d canceled, will expire at end of period", userID)
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.Printf("Google Webhook: User %d subscription on hold", userID)
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.Printf("Google Webhook: User %d in grace period", userID)
log.Warn().Uint("user_id", userID).Msg("Google Webhook: User in grace period")
return nil
}
@@ -702,7 +700,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notifica
return err
}
log.Printf("Google Webhook: User %d restarted subscription", userID)
log.Info().Uint("user_id", userID).Msg("Google Webhook: User restarted subscription")
return nil
}
@@ -712,7 +710,7 @@ func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notificati
return err
}
log.Printf("Google Webhook: User %d subscription revoked", userID)
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription revoked")
return nil
}
@@ -722,13 +720,13 @@ func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notificati
return err
}
log.Printf("Google Webhook: User %d subscription expired", userID)
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription expired")
return nil
}
func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription paused by user
log.Printf("Google Webhook: User %d subscription paused", userID)
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription paused")
return nil
}
@@ -736,18 +734,21 @@ func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notificatio
// Signature Verification (Optional but Recommended)
// ====================
// VerifyAppleSignature verifies the JWS signature using Apple's root certificate
// This is optional but recommended for production
// 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 {
// Load Apple's root certificate if not already loaded
// Deny by default when root certificates are not loaded.
if h.appleRootCerts == nil {
// Apple's root certificates can be downloaded from:
// https://www.apple.com/certificateauthority/
// You'd typically embed these or load from a file
return nil // Skip verification for now
return fmt.Errorf("Apple root certificates not configured: cannot verify JWS signature")
}
// Parse the JWS token
// 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{})
@@ -755,21 +756,46 @@ func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string)
return nil, fmt.Errorf("missing x5c header")
}
// Decode the first certificate (leaf)
// 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)
}
cert, err := x509.ParseCertificate(certData)
leafCert, err := x509.ParseCertificate(certData)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Verify the certificate chain (simplified)
// In production, you should verify the full chain
// 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)
}
return cert.PublicKey.(*ecdsa.PublicKey), nil
// 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 {
@@ -783,13 +809,58 @@ func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string)
return nil
}
// VerifyGooglePubSubToken verifies the Pub/Sub push token (if configured)
// 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 {
// If you configured a push endpoint with authentication, verify here
// The token is typically in the Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
log.Warn().Msg("Google Webhook: missing Authorization header")
return false
}
// Expect "Bearer <token>" 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
}
// For now, we rely on the endpoint being protected by your infrastructure
// (e.g., only accessible from Google's IP ranges)
return true
}