Skip to content

Commit

Permalink
feat: handle play store developer notifications (#2560)
Browse files Browse the repository at this point in the history
* chore: run go mod tidy

* chore: tidy up existing code

* test: update tests

* chore: use better receiver name

* feat: parse play store dev notification and test

* feat: scaffold processing

* chore: mute linter

* feat: determine whether to process ntf or not

* test: add tests for playStoreDevNotification

* test: add more tests

* feat: process notification

* feat: clean up

* test: add tests for purchaseToken

* feat: expose play store params

* feat: skip any notifications before 2024-06-01

* chore: delete garbage from original implementation

* feat: handle disabled notifications case

* fix: fix typo

* feat: use explicitly utc on unix timestampts
  • Loading branch information
pavelbrm authored Jul 16, 2024
1 parent 7361af8 commit c7749a2
Show file tree
Hide file tree
Showing 14 changed files with 1,594 additions and 712 deletions.
25 changes: 25 additions & 0 deletions services/skus/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"strings"
"time"

"github.com/awa/go-iap/appstore"
"github.com/square/go-jose"
Expand Down Expand Up @@ -306,3 +307,27 @@ func extractPubKey(raw *x509.Certificate) (*ecdsa.PublicKey, error) {
return nil, errInvalidASSNPubKeyType
}
}

func shouldCancelOrderIOS(info *appstore.JWSTransactionDecodedPayload, now time.Time) bool {
tx := (*appStoreTransaction)(info)

return tx.hasExpired(now) || tx.isRevoked(now)
}

type appStoreTransaction appstore.JWSTransactionDecodedPayload

func (x *appStoreTransaction) hasExpired(now time.Time) bool {
if x == nil {
return false
}

return x.ExpiresDate > 0 && now.After(time.UnixMilli(x.ExpiresDate))
}

func (x *appStoreTransaction) isRevoked(now time.Time) bool {
if x == nil {
return false
}

return x.RevocationDate > 0 && now.After(time.UnixMilli(x.RevocationDate))
}
116 changes: 116 additions & 0 deletions services/skus/appstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package skus

import (
"testing"
"time"

"github.com/awa/go-iap/appstore"
should "github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -261,3 +262,118 @@ func TestAppStoreSrvNotification_effect(t *testing.T) {
})
}
}

func TestShouldCancelOrderIOS(t *testing.T) {
type tcGiven struct {
now time.Time
info *appstore.JWSTransactionDecodedPayload
}

type testCase struct {
name string
given tcGiven
exp bool
}

tests := []testCase{
{
name: "nil",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
},
},

{
name: "empty_dates_not_expired",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{},
},
},

{
name: "expires_date_before_no_revocation_date",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2023-12-31 23:59:59.
ExpiresDate: 1704067199000,
},
},
exp: true,
},

{
name: "expires_date_after_no_revocation_date",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2024-01-01 01:00:01.
ExpiresDate: 1704070801000,
},
},
},

{
name: "expires_date_after_revocation_date_after",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2024-01-01 01:00:01.
ExpiresDate: 1704070801000,

// 2024-01-01 00:30:01.
RevocationDate: 1704069001000,
},
},
},

{
name: "expires_date_after_revocation_date_before",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2024-01-01 01:00:01.
ExpiresDate: 1704070801000,

// 2023-12-31 23:30:01.
RevocationDate: 1704065401000,
},
},
exp: true,
},

{
name: "no_expires_date_revocation_date_before",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2023-12-31 23:59:59.
RevocationDate: 1704067199000,
},
},
exp: true,
},

{
name: "no_expires_date_revocation_date_after",
given: tcGiven{
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
info: &appstore.JWSTransactionDecodedPayload{
// 2024-01-01 01:00:01.
RevocationDate: 1704070801000,
},
},
},
}

for i := range tests {
tc := tests[i]

t.Run(tc.name, func(t *testing.T) {
actual := shouldCancelOrderIOS(tc.given.info, tc.given.now)

should.Equal(t, tc.exp, actual)
})
}
}
169 changes: 65 additions & 104 deletions services/skus/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"net/http"
"os"
"strconv"
"strings"

"github.com/asaskevich/govalidator"
"github.com/go-chi/chi"
Expand All @@ -20,7 +19,6 @@ import (
uuid "github.com/satori/go.uuid"
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/webhook"
"google.golang.org/api/idtoken"

"github.com/brave-intl/bat-go/libs/clients/radom"
appctx "github.com/brave-intl/bat-go/libs/context"
Expand Down Expand Up @@ -985,144 +983,107 @@ func VerifyCredentialV1(service *Service) handlers.AppHandler {
}
}

// WebhookRouter - handles calls from various payment method webhooks informing payments of completion
func WebhookRouter(svc *Service) chi.Router {
r := chi.NewRouter()

r.Method(http.MethodPost, "/stripe", middleware.InstrumentHandler("HandleStripeWebhook", handleStripeWebhook(svc)))
r.Method(http.MethodPost, "/radom", middleware.InstrumentHandler("HandleRadomWebhook", HandleRadomWebhook(svc)))
r.Method(http.MethodPost, "/android", middleware.InstrumentHandler("HandleAndroidWebhook", HandleAndroidWebhook(svc)))
r.Method(http.MethodPost, "/android", middleware.InstrumentHandler("handleWebhookPlayStore", handleWebhookPlayStore(svc)))
r.Method(http.MethodPost, "/ios", middleware.InstrumentHandler("handleWebhookAppStore", handleWebhookAppStore(svc)))

return r
}

func HandleAndroidWebhook(service *Service) handlers.AppHandler {
func handleWebhookPlayStore(svc *Service) handlers.AppHandler {
return func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
ctx := r.Context()

l := logging.Logger(ctx, "payments").With().Str("func", "HandleAndroidWebhook").Logger()

if err := service.gcpValidator.validate(ctx, r); err != nil {
l.Error().Err(err).Msg("invalid request")
return handlers.WrapError(err, "invalid request", http.StatusUnauthorized)
}

payload, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB))
if err != nil {
l.Error().Err(err).Msg("failed to read payload")
return handlers.WrapValidationError(err)
}

l.Info().Str("payload", string(payload)).Msg("")
return handleWebhookPlayStoreH(w, r, svc)
}
}

var validationErrMap = map[string]interface{}{}
func handleWebhookPlayStoreH(w http.ResponseWriter, r *http.Request, svc *Service) *handlers.AppError {
ctx := r.Context()

var req AndroidNotification
if err := inputs.DecodeAndValidate(context.Background(), &req, payload); err != nil {
validationErrMap["request-body-decode"] = err.Error()
l.Error().Interface("validation_map", validationErrMap).Msg("validation_error")
return handlers.ValidationError("Error validating request", validationErrMap)
}
lg := logging.Logger(ctx, "skus").With().Str("func", "handleWebhookPlayStore").Logger()

l.Info().Interface("req", req).Msg("")
if err := svc.gpsAuth.authenticate(ctx, r.Header.Get("Authorization")); err != nil {
if errors.Is(err, errGPSDisabled) {
lg.Warn().Msg("play store notifications disabled")

dn, err := req.Message.GetDeveloperNotification()
if err != nil {
validationErrMap["invalid-developer-notification"] = err.Error()
l.Error().Interface("validation_map", validationErrMap).Msg("validation_error")
return handlers.ValidationError("Error validating request", validationErrMap)
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
}

l.Info().Interface("developer_notification", dn).Msg("")
lg.Err(err).Msg("invalid request")

if dn == nil || dn.SubscriptionNotification.PurchaseToken == "" {
validationErrMap["invalid-developer-notification-token"] = "notification has no purchase token"
l.Error().Interface("validation_map", validationErrMap).Msg("validation_error")
return handlers.ValidationError("Error validating request", validationErrMap)
}
return handlers.WrapError(err, "invalid request", http.StatusUnauthorized)
}

l.Info().Msg("verify_developer_notification")
data, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB))
if err != nil {
lg.Err(err).Msg("failed to read payload")

if err := service.verifyDeveloperNotification(ctx, dn); err != nil {
l.Error().Err(err).Msg("failed to verify subscription notification")
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
}

switch {
case errors.Is(err, errNotFound), errors.Is(err, model.ErrOrderNotFound):
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
default:
return handlers.WrapError(err, "failed to verify subscription notification", http.StatusInternalServerError)
}
}
ntf, err := parsePlayStoreDevNotification(data)
if err != nil {
lg.Err(err).Str("payload", string(data)).Msg("failed to parse play store notification")

return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
return handlers.ValidationError("request", map[string]interface{}{"parse-payload": err.Error()})
}
}

const (
errAuthHeaderEmpty model.Error = "skus: gcp authorization header is empty"
errAuthHeaderFormat model.Error = "skus: gcp authorization header invalid format"
errInvalidIssuer model.Error = "skus: gcp invalid issuer"
errInvalidEmail model.Error = "skus: gcp invalid email"
errEmailNotVerified model.Error = "skus: gcp email not verified"
)
if err := svc.processPlayStoreNotification(ctx, ntf); err != nil {
l := lg.With().Str("ntf_type", ntf.ntfType()).Int("ntf_subtype", ntf.ntfSubType()).Str("ntf_effect", ntf.effect()).Logger()

type gcpTokenValidator interface {
Validate(ctx context.Context, idToken string, audience string) (*idtoken.Payload, error)
}
switch {
case errors.Is(err, context.Canceled):
l.Warn().Err(err).Msg("failed to process play store notification")

type gcpValidatorConfig struct {
audience string
issuer string
serviceAccount string
disabled bool
}
// Should retry.
return handlers.WrapError(model.ErrSomethingWentWrong, "request has been cancelled", model.StatusClientClosedConn)

type gcpPushNotificationValidator struct {
validator gcpTokenValidator
cfg gcpValidatorConfig
}
case errors.Is(err, model.ErrOrderNotFound), errors.Is(err, errNotFound):
l.Warn().Err(err).Msg("failed to process play store notification")

func newGcpPushNotificationValidator(gcpTokenValidator gcpTokenValidator, cfg gcpValidatorConfig) *gcpPushNotificationValidator {
return &gcpPushNotificationValidator{
validator: gcpTokenValidator,
cfg: cfg,
}
}
// Order was not found, so nothing can be done.
// It might be an issue for VPN:
// - user has not linked yet;
// - billing cycle comes through, subscription renews;
// - user links immediately after billing cycle (they get 1 month);
// - there might be a small gap between order's expiration and next successful renewal;
// - the grace period should handle it.
// A better option is to create orders when the user subscribes, similar to Leo.
// Or allow for 1 or 2 retry attempts for the notification, but this requires tracking.

func (g *gcpPushNotificationValidator) validate(ctx context.Context, r *http.Request) error {
if g.cfg.disabled {
return nil
}
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)

ah := r.Header.Get("Authorization")
if ah == "" {
return errAuthHeaderEmpty
}
case errors.Is(err, model.ErrNoRowsChangedOrder), errors.Is(err, model.ErrNoRowsChangedOrderPayHistory):
l.Warn().Err(err).Msg("failed to process play store notification")

token := strings.Split(ah, " ")
if len(token) != 2 {
return errAuthHeaderFormat
}
// No rows have changed whilst processing.
// This could happen in theory, but not in practice.
// It would mean that we attempted to update with the same data as it's in the database.
// This could happen when trying to process the same event twice, which could happen
// if the App Store sends multiple notifications about the same event.
// (E.g. auto-renew and billing recovery).

p, err := g.validator.Validate(ctx, token[1], g.cfg.audience)
if err != nil {
return fmt.Errorf("invalid authentication token: %w", err)
}
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)

if p.Issuer == "" || p.Issuer != g.cfg.issuer {
return errInvalidIssuer
}
default:
l.Err(err).Msg("failed to process play store notification")

if p.Claims["email"] != g.cfg.serviceAccount {
return errInvalidEmail
// Retry for all other errors for now.
return handlers.WrapError(model.ErrSomethingWentWrong, "something went wrong", http.StatusInternalServerError)
}
}

if p.Claims["email_verified"] != true {
return errEmailNotVerified
msg := "skipped play store notification"
if ntf.shouldProcess() {
msg = "processed play store notification"
}

return nil
lg.Info().Str("ntf_type", ntf.ntfType()).Int("ntf_subtype", ntf.ntfSubType()).Str("ntf_effect", ntf.effect()).Msg(msg)

return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
}

func handleWebhookAppStore(svc *Service) handlers.AppHandler {
Expand Down Expand Up @@ -1666,10 +1627,10 @@ func handleReceiptErr(err error) *handlers.AppError {
case errors.Is(err, errIOSPurchaseNotFound):
result.ErrorCode = "purchase_not_found"

case errors.Is(err, errExpiredGPSSubPurchase):
case errors.Is(err, errGPSSubPurchaseExpired):
result.ErrorCode = "purchase_expired"

case errors.Is(err, errPendingGPSSubPurchase):
case errors.Is(err, errGPSSubPurchasePending):
result.ErrorCode = "purchase_pending"

default:
Expand Down
Loading

0 comments on commit c7749a2

Please sign in to comment.