Skip to content

feat(api): native Google Play Billing verify + RTDN with unified Pro source - #194

Merged
thomasluizon merged 6 commits into
mainfrom
feature/play-billing
Jun 8, 2026
Merged

feat(api): native Google Play Billing verify + RTDN with unified Pro source#194
thomasluizon merged 6 commits into
mainfrom
feature/play-billing

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Issue

Refs thomasluizon/orbit-ui-mobile#132 — cross-repo, paired with the orbit-ui-mobile PR. Merge this one first (mobile calls /play/verify).

Summary

Adds native Google Play Billing server support so the Android app can sell Orbit Pro through the native Play purchase sheet (Play-policy compliant), while the web app keeps Stripe. The backend remains the single source of truth for one Pro entitlement, now carrying a purchase source (stripe | play).

What's included

  • POST /api/subscriptions/play/verify — verifies a Play purchase via the Play Developer API (purchases.subscriptionsv2.get), grants Pro, and acknowledges server-side within Google's 3-day window. Reconciles the "has both" case (skips a downgrade when an active Stripe sub covers a later period).
  • POST /api/subscriptions/play/rtdn — Real-time Developer Notifications webhook (Pub/Sub push, OIDC-verified when RtdnAudience is set). Decodes the envelope only to learn which purchase token changed, then re-verifies authoritative state (never trusts the payload) and grants/cancels accordingly.
  • New IPlayBillingService seam (Application) + GooglePlayBillingService (Infrastructure, Google.Apis.AndroidPublisher.v3), mirroring the Stripe IBillingService pattern.
  • User gains SubscriptionSource + PlayPurchaseToken (EF migration AddPlayBillingFields, back-fills existing Stripe subscribers to source=stripe). PayGate is unchanged. source is surfaced on the profile + subscription-status DTOs.

Tests

Domain (SetPlaySubscription/CancelSubscription), verify-handler (grant/ack/has-both/inactive/error), RTDN-handler (active/inactive/linked-token/malformed/no-user/error), validator, and PlayBillingIntegrationTests via a CapturingPlayBillingService. All 3,100 unit tests pass; the feature's 2 integration tests pass. (10 unrelated, pre-existing integration failures in the AI + api-key suites — live-OpenAI non-determinism and a Pro-gating setup issue — documented in the report.)

Deploy / manual

Set GooglePlay__* on Render (package, service-account JSON, product/base-plan ids, RTDN audience + push SA email) and create the Pub/Sub push subscription → /api/subscriptions/play/rtdn.

🤖 Generated with Claude Code

…source

Add a Play-purchase verify endpoint (Play Developer API subscriptionsv2.get + server-side acknowledge) and an RTDN Pub/Sub webhook that re-verifies authoritative state, behind a new IPlayBillingService seam. The User entitlement gains SubscriptionSource (stripe|play) + PlayPurchaseToken; PayGate is unchanged and the web Stripe flow is untouched.

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Paired UI PR (closes the issue): thomasluizon/orbit-ui-mobile#147 — merge this api PR first.

Comment thread src/Orbit.Application/Subscriptions/Commands/VerifyPlayPurchaseCommand.cs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness bug that needs a fix before this lands.

StripeCoversLaterPeriod early-return skips both acknowledgement and token persistence (VerifyPlayPurchaseCommand.cs:42-46)

When a user with an active Stripe subscription (expiring later) verifies a Play purchase, the handler returns early without:

  1. Acknowledging the purchase. Google's 3-day window applies to every new subscription purchase regardless of local entitlement logic. Skipping AcknowledgeAsync here means Google auto-refunds the user and voids the subscription — the app shows success but the purchase evaporates silently.

  2. Saving PlayPurchaseToken. Every future RTDN for that token (renewals, cancellations) will find no matching user, so the Play subscription is permanently invisible to the backend. When Stripe later expires and its webhook fires CancelSubscription(), the user drops to free even though they are still paying for Play.

Fix: move AcknowledgeAsync outside (before) the StripeCoversLaterPeriod branch, and add a User.LinkPlayPurchaseToken domain method that saves the token without altering PlanExpiresAt, called unconditionally before returning in that branch. The existing test for this path should also assert both behaviors.

Everything else in the PR is well-structured: the OIDC verification chain for RTDN, the EntitledStates set (CANCELED = still-active-until-period-end is correct per the Play v2 API), the migration backfill, the IPlayBillingService seam, and test coverage for all other handler paths are solid.

… period

The StripeCoversLaterPeriod early return skipped purchase acknowledgement
(Google auto-refunds unacknowledged purchases within 3 days) and
PlayPurchaseToken persistence (leaving RTDN unable to match the user). When
Stripe later cancelled, the user would drop to free despite an active Play
subscription. Acknowledge unconditionally before the branch and link the
token via a new User.LinkPlayPurchaseToken that preserves Stripe's later
expiry and source.

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Addressed in 52e30e5.

StripeCoversLaterPeriod early return — unacknowledged purchase + orphaned token. The branch now:

  • Acknowledges unconditionally — moved AcknowledgeAsync (when !IsAcknowledged) before the branch, so Google's 3-day auto-refund can't silently void a paid subscription.
  • Persists the token — added User.LinkPlayPurchaseToken(token) which sets only PlayPurchaseToken, leaving Stripe's later PlanExpiresAt/SubscriptionSource intact, then calls SaveChangesAsync. RTDN can now match the user for renewals/cancellations.

Test renamed to Handle_StripeCoversLaterPeriod_LinksTokenAndAcknowledgesWithoutShorteningExpiry and now asserts the token is saved, AcknowledgeAsync + SaveChangesAsync are called, and the Stripe expiry is not shortened.

Build clean; handler tests 7/7, domain 347/347.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in commit 52e30e5 correctly addresses both issues I flagged: AcknowledgeAsync is now called unconditionally before the StripeCoversLaterPeriod branch (lines 42-52 of VerifyPlayPurchaseCommand.cs), and user.LinkPlayPurchaseToken is called with SaveChangesAsync in the early-return path so RTDN can always match the token. The updated test Handle_StripeCoversLaterPeriod_LinksTokenAndAcknowledgesWithoutShorteningExpiry asserts all three behaviors (acknowledge called, token persisted, Stripe expiry preserved). The rest of the PR — OIDC verification chain for RTDN, EntitledStates set, migration backfill, IPlayBillingService seam, and full handler/domain/integration test coverage — remains solid. Good to merge.

Split CancelSubscription into CancelStripeSubscription and
CancelPlaySubscription so a cancellation from one provider no longer wipes
the other provider's still-active entitlement. Each clears the shared Pro
slot only when it owns the active SubscriptionSource, and never clears the
other source's token: the Stripe webhook cancelling a web subscription now
preserves an active Play subscription, and even in the linked-token window
it keeps PlayPurchaseToken so the next RTDN self-heals. Routes the Stripe
webhook to CancelStripeSubscription and the Play RTDN inactive path to
CancelPlaySubscription.

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Follow-up in 48c3c8e — closed the dual-pay edge I flagged on the prior fix.

Source-aware cancellation. User.CancelSubscription() is split into CancelStripeSubscription() / CancelPlaySubscription() (sharing a private ClearEntitlement()). Each clears the shared Pro slot only when it owns the active SubscriptionSource, and never touches the other source's token:

  • Stripe webhook (subscription.deleted + updated→canceled/unpaid) → CancelStripeSubscription(). If Play is the active source, only StripeSubscriptionId is cleared — Play entitlement survives. Even when Stripe is the active source but a Play token was linked (the StripeCoversLaterPeriod window), PlayPurchaseToken is preserved so the next RTDN re-grants Pro.
  • Play RTDN inactive path → CancelPlaySubscription() (symmetric: keeps an active Stripe entitlement).

Net effect: a web→Android migrator who cancels Stripe while Play is active no longer drops to free.

Domain 350/350 (2 tests renamed + 3 added for the dual-pay matrix), all Subscriptions handler/query tests 77/77, build clean.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source-aware cancellation commit (48c3c8e) correctly resolves the dual-pay edge case. CancelSubscription() is split symmetrically into CancelStripeSubscription() / CancelPlaySubscription(): each clears its own provider's token unconditionally, and delegates to the private ClearEntitlement() only when it owns the active SubscriptionSource. The private helper clears the shared entitlement fields (Plan, PlanExpiresAt, SubscriptionInterval, SubscriptionSource) but intentionally never touches provider tokens, which is exactly right — it means a linked Play token survives a Stripe cancellation for later RTDN self-healing, and vice versa. All five dual-pay matrix scenarios are covered by domain tests. Good to merge.

@thomasluizon

Copy link
Copy Markdown
Owner Author

Code Review: Native Google Play Billing — orbit-api #194 + orbit-ui-mobile #147

Scope: both PRs (one cross-repo feature). Focus: best-practice implementation + security (this is a payments/entitlement surface).
Method: two independent fresh-eyes audits, then manual verification of every security-critical claim against the actual source (controller, RTDN handler, verify handler, billing service, DI/settings, EF migration + model config).
Recommendation: NEEDS WORK — one Critical paywall-integrity hole (plus its DB-enforcement half) should land before relying on Play revenue. Everything else is solid, and the mobile PR is clean.

Severity summary

Severity Count
Critical 1
High 1
Medium 3
Low 4

Critical

C1 — A purchase token is never bound to the calling user → "buy once, share the token, get Pro on unlimited accounts"

Files: src/Orbit.Application/Subscriptions/Commands/VerifyPlayPurchaseCommand.cs (handler), src/Orbit.Infrastructure/Services/GooglePlayBillingService.cs:26-64 (VerifyAsync), src/Orbit.Domain/Entities/User.cs:158 (SetPlaySubscription).

POST /api/subscriptions/play/verify ( [Authorize] ) takes the caller's JWT user id and an arbitrary purchaseToken from the body, asks Google whether that token is active, and if so grants Pro to the caller — with no check that the token belongs to that user and no check that another account already owns it. VerifyAsync never reads SubscriptionPurchaseV2.ExternalAccountIdentifiers, and the handler never queries for an existing owner.

Contrast the Stripe path: HandleWebhookCommand binds a purchase to a user via the server-set session.Metadata["userId"] created at checkout time, so a stranger's Stripe event can't be claimed. The Play path has no equivalent binding.

Exploit: any authenticated user who obtains any active orbit_pro purchase token (their own extracted token, a shared/resold token, a leaked one) can POST it and receive Pro. One real subscription → Pro on N accounts. This defeats the paywall the feature exists to enforce. (Not user-data exposure or victim account-takeover — the loss is revenue/entitlement integrity.)

Fix (defense in depth, do both):

  1. Bind at purchase time. Mobile passes a server-known per-user value as obfuscatedAccountIdAndroid on requestPurchase; backend reads purchase.ExternalAccountIdentifiers.ObfuscatedExternalAccountId in VerifyAsync and rejects when it doesn't match the calling user. (No Play purchases exist yet — ideal time to add this.)
  2. Reject already-owned tokens. Before granting, FindOneTrackedAsync(u => u.PlayPurchaseToken == token && u.Id != userId) → refuse if found. Pair with H1 so the DB makes it atomic.

High

H1 — No DB uniqueness on PlayPurchaseToken (the enforcement half of C1; also a verify/RTDN double-grant race)

Files: src/Orbit.Infrastructure/Migrations/20260606230033_AddPlayBillingFields.cs:13-17 (plain nullable text, no index), src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs:292-311 (ConfigureUserEntity indexes only Email + ReferralCode).

The verify handler is read-modify-write (FindOneTrackedAsyncSaveChangesAsync) with no transaction or concurrency token, so even with an app-level "already owned?" check, two concurrent claims of the same token both pass and both win. The same race exists between a verify and a concurrent RTDN. Only the database can make "one token = one entitlement" atomic.

Fix: add a unique filtered index mirroring the existing ReferralCode pattern —
entity.HasIndex(u => u.PlayPurchaseToken).IsUnique().HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); — in a new migration, and treat the resulting DbUpdateException in the handler as "token already claimed."


Medium

M1 — RTDN authentication is fail-open by construction

File: src/Orbit.Api/Controllers/SubscriptionController.cs:142-147.

if (!string.IsNullOrEmpty(settings.RtdnAudience)
    && !await IsValidPushTokenAsync(...))
    return Unauthorized();

If RtdnAudience is empty, the OIDC check is skipped and this [AllowAnonymous] endpoint accepts any anonymous POST. Mitigations confirmed (so this is Medium, not High): GooglePlaySettings.Validate() requires RtdnAudience and runs at startup outside Development (ServiceCollectionExtensions.cs:42-43), so a configured prod refuses to boot without it; the handler always re-verifies with Google before granting (a forged payload can't fabricate entitlement); and if the GooglePlay section is missing entirely, resolving IPlayBillingService throws while building the Google client (CredentialFactory.FromJson("")), yielding 500 rather than a grant. Still, the design is fail-open and is fully open in Development.

Fix: make it fail-closed — require a valid push token unconditionally on this endpoint (drop the IsNullOrEmpty(RtdnAudience) guard), and resolve+Validate() the section non-conditionally (drop the ?.) so the endpoint can't be live without OIDC configured.

M2 — RTDN has no replay / message-id idempotency

File: src/Orbit.Application/Subscriptions/Commands/HandlePlayNotificationCommand.cs.
Pub/Sub is at-least-once; the envelope messageId is never persisted/checked, so redelivered or replayed pushes re-run VerifyAsync + SaveChanges. Re-verification makes each cycle effectively idempotent, but combined with H1 it widens the double-write window, and the IsActive==false branch can cancel an entitlement a near-simultaneous verify just granted (grant/cancel ordering race).

Fix: persist processed messageIds (unique-index dedup table, mirroring the StreakFreeze/rate-limit idempotency pattern) and/or wrap verify+grant in a transaction once H1's unique index exists.

M3 — /play/verify maps an upstream 5xx to HTTP 400

Files: VerifyPlayPurchaseCommand.cs:30-34 (BillingProviderExceptionResult.Failure("Payment service temporarily unavailable")), surfaced as 400 by ToPayGateAwareResult. The action advertises [ProducesResponseType(401/403)] it never returns. A Google outage tells the client "bad request," skewing retry/telemetry on a payment path. (Error strings are static and safe — no secret/PII leak.)

Fix: map provider-unavailable to 503 (or a distinct error code the client branches on) and align the ProducesResponseType attributes with reality.


Low

L1 — Acknowledge failure is swallowed during verify (revenue tradeoff — confirm intent)

VerifyPlayPurchaseCommand.cs grants Pro even if AcknowledgeAsync throws (logged via LogAcknowledgeError). Google auto-refunds unacknowledged purchases after 3 days, so a transient ack failure → Pro granted, money refunded, until RTDN/expiry reconciles. It logs (so it doesn't violate the no-silent-swallow rule) and self-heals via RTDN, but the business consequence should be deliberate. Consider a retry/reconciler for failed acks.

L2 — RTDN auth failures are not logged

SubscriptionController.cs:158-176 returns a bare Unauthorized() for both a forged token and a legit-Google-but-wrong-SA token. Operationally you can't distinguish misconfiguration from attack. Log (without the token) on validation failure.

L3 — (mobile) Restore gives no feedback when there's nothing to restore

apps/mobile/app/upgrade.tsx discards restorePurchases()'s boolean. On success the profile refetch flips into the billing dashboard (implicit), on failure an error shows — but when getAvailablePurchases() returns zero, the tap does nothing visible. Surface a neutral "no purchases found" message (new i18n key in both locales).

L4 — (mobile) Restore aborts remaining tokens on the first verify failure

apps/mobile/hooks/use-play-billing.ts:154-171 runs verifies sequentially under one try/catch. With a single orbit_pro SKU the blast radius is nil today; if multiple SKUs ever ship, wrap each verify so one bad token doesn't mask a good restore. Note only — don't over-engineer for a one-product catalog.


What's done well

  • RTDN trust model is correct: the push payload is decoded only to learn the changed token; authoritative state always comes from a fresh purchases.subscriptionsv2.get before any grant/cancel. Forged payloads can't fabricate entitlement.
  • RTDN authorization (when enabled) is textbook: validates the Google-signed JWT, requires EmailVerified, and pins to the exact authorized push SA email — not just "any Google token."
  • Mobile entitlement trust model is airtight: the client discards the verify response and reflects Pro only after re-fetching the server profile (invalidateEntitlement); it never self-grants. finishTransaction runs only after server verify succeeds, so a failed verify can't strand a charged-but-unentitled user (Play redelivers).
  • No secret leakage: SA JSON loaded from env/config, never committed, never logged; Validate() reports key names only.
  • Source-aware cancellation (latest commit) correctly preserves the other provider's active entitlement and the linked token.
  • Contract parity is exact: PlayVerifyResponseplayVerifyResponseSchema; subscriptionSource enum "stripe"|"play"|null matches; request body matches playVerifyRequestSchema.
  • Strong, well-targeted test coverage on both sides (grant/ack/inactive/throw/has-both/linked-token/malformed/no-user; mobile verify→invalidate→finish, restore success + failure).
  • Clean platform split: native IAP isolated to mobile, web Stripe untouched, shared contract mirrored — exactly the allowed monorepo divergence.

Validation

Check Result
orbit-api dotnet build PASS
orbit-api Domain.Tests PASS (350)
orbit-api Subscriptions tests PASS (77)
mobile type-check + lint + vitest PASS (9 hook tests; 398 total this session)
shared type-check + vitest PASS (852)
Parity / i18n / contract PASS (mirrored; allowed adapter divergence)

Recommendation

Land C1 + H1 together (token→user binding + unique index) before relying on Play revenue — they're the same attack from the app and DB angles, and now (zero existing Play purchases) is the cheapest time to add obfuscatedAccountId binding. Then make RTDN fail-closed (M1) and add message-id idempotency (M2). M3 + the Low items are follow-ups. The mobile PR (#147) is approvable as-is; its only items are minor UX polish.

…closed auth

Security hardening from the review of #194 (paywall integrity + RTDN):

- Purchase-token binding (C1/H1): the mobile client stamps the buyer's
  user id as Play's obfuscatedAccountId; VerifyAsync surfaces it and the
  verify handler rejects a token whose account id does not match the JWT
  caller (PLAY_PURCHASE_ACCOUNT_MISMATCH). A unique filtered index on
  Users.PlayPurchaseToken makes one-token-one-account atomic. Together these
  close 'buy once, share the token, get Pro on many accounts.'
- RTDN fail-closed (M1): /play/rtdn always requires a valid Google OIDC push
  token (no RtdnAudience-empty bypass), and startup validation throws when
  the GooglePlay section is absent instead of booting unauthenticated.
- RTDN idempotency (M2): a ProcessedPlayNotification dedup table + Pub/Sub
  messageId short-circuit make at-least-once redelivery safe.

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Full hardening implemented in bda6b24 — all four selected findings addressed.

C1 + H1 — token bound to the buyer (paywall fix). The mobile client now stamps the signed-in user's id as Play's obfuscatedAccountId; GooglePlayBillingService.VerifyAsync surfaces it, and VerifyPlayPurchaseCommandHandler rejects (PLAY_PURCHASE_ACCOUNT_MISMATCH) any token whose account id doesn't match the JWT caller (Guid comparison, format-robust). A unique filtered index on Users.PlayPurchaseToken (migration AddPlayTokenUniqueIndexAndNotificationDedup) makes one-token-one-account atomic. "Buy once, share the token" is now closed at both the app and DB layers.

M1 — RTDN fail-closed. /play/rtdn always requires a valid Google OIDC push token (removed the RtdnAudience-empty bypass), and startup validation throws when the GooglePlay section is absent — no more booting an unauthenticated webhook.

M2 — RTDN idempotency. New ProcessedPlayNotification dedup table + Pub/Sub messageId short-circuit; the unique index + Pub/Sub retry cover the concurrent-redelivery race without leaking EF into the Application layer.

Validation: build clean · Domain 350 · Subscriptions 79 (incl. new account-mismatch + dedup tests) · Play integration 2/2 (migration applies + binding grants end-to-end). Paired mobile change: thomasluizon/orbit-ui-mobile#147 (f996942).

Not in this pass (non-blocking follow-ups): M3 (5xx→400 mapping), L1–L4.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final commit (bda6b24) addresses every security and correctness concern from the previous review cycle cleanly. The RTDN endpoint is now fully fail-closed: the !string.IsNullOrEmpty bypass is gone, startup validation can no longer skip on a null config object, and the OIDC check is always enforced. The ObfuscatedAccountId field is propagated from the Play API all the way to the verify handler, where it is compared against the authenticated user's GUID before any grant — correctly handling the unparseable-ID case as a hard mismatch. RTDN idempotency is backed by a ProcessedPlayNotifications table with a unique index on MessageId, written atomically with the user-state change in a single SaveChangesAsync so there is no window for partial state. All handler paths (grant, cancel, linked-token, duplicate, verify-throws, account-mismatch) are covered by unit tests; the integration test now threads the real _userId into the stub state so the ownership check passes end-to-end. Good to merge.

…on dedup

Security review follow-up on #194:

- Paywall integrity: VerifyAsync's IsActive only meant "an active subscription
  exists on the package" -- the configured ProductId and a recognized base plan
  were never checked, so any active subscription under the package (or one with
  a null/unrecognized base plan) granted full Pro. Gate both the verify handler
  and the RTDN handler on a shared PlaySubscriptionState.GrantsOrbitPro rule
  (active AND the configured product AND a monthly/yearly base plan).
- RTDN idempotency: the AnyAsync-then-AddAsync dedup raced under concurrent
  redelivery, surfacing the loser as an unhandled 500. Catch the unique-index
  DbUpdateException and treat a confirmed-already-recorded messageId as success.

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Security re-review (pass #2) — fixes pushed in 5f98924

Re-reviewed with three independent adversarial auditors; every confirmed finding re-verified against source. The first hardening round holds. Two issues found on this repo, both fixed:

[HIGH] Purchased product was never validated. VerifyAsync.IsActive only meant "an active subscription exists on the package" — the configured ProductId was dead outside startup validation, and a null/unrecognized base-plan interval still granted Pro. So any active subscription under org.useorbit.app (or one with an unknown base plan) granted full Pro — latent today (single SKU), exploitable the moment a second/cheaper product exists. Fixed via a shared PlaySubscriptionState.GrantsOrbitPro(settings) rule (active AND configured product AND monthly/yearly base plan), enforced on both verify and RTDN.

[MEDIUM] RTDN dedup race. AnyAsync-then-AddAsync raced under concurrent redelivery; the loser threw an unhandled DbUpdateException → 500. Now caught and treated as a duplicate success (re-checks AnyAsync in the catch, no Npgsql dependency in Application).

Confirmed robust (independently re-verified): OIDC bound to the push SA email (not the "any Google-signed token" pitfall), fail-closed startup + webhook, atomic dedup+entitlement commit, source-aware cancellation, Google-authoritative state mapping (PENDING/PAUSED/etc. never grant), upstream failures fail closed.

Deferred (non-blocking): bound + TTL-prune ProcessedPlayNotification.MessageId; map the verify unique-index TOCTOU 500→409; add OIDC-validator + /play/rtdn integration tests.

Validation: build clean · Application 1781 · Domain 350 · Play integration 2/2.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final commit (5f98924) rounds out the hardening cycle cleanly. GrantsOrbitPro consolidates the entitlement rule into one testable place — IsActive && Interval is not null && ProductId == settings.ProductId — so neither the verify handler nor the RTDN handler can grant Pro for an unrecognized base plan or a different product under the same package, even with a valid Google-issued token. The race-safe dedup fix is also correct: the DbUpdateException catch is scoped to !string.IsNullOrEmpty(decoded.MessageId), then re-confirms the messageId is recorded before swallowing — a different DB error still surfaces as an exception, and a legitimate concurrent duplicate correctly returns success because the winning concurrent request already applied the state change. All five commits together ship a complete, defense-in-depth Play Billing integration: server-side verify + acknowledge, RTDN fail-closed with OIDC, token→buyer binding at both app and DB layers, source-aware cancellation, product/plan gating, and idempotent dedup. Good to merge.

…rd, dedup bound+prune, OIDC seam)

Implements every deferred item from the security review:

- Verify token TOCTOU: a unique-index violation on PlayPurchaseToken now maps to a clean
  PLAY_PURCHASE_ACCOUNT_MISMATCH instead of a 500 (re-checks ownership in the catch, no Npgsql leak).
- RTDN defense-in-depth: skip a notification whose verified ObfuscatedAccountId does not match the
  resolved user, so the token->user binding is re-asserted rather than trusted.
- Dedup table bounded + pruned: ProcessedPlayNotification.MessageId is now varchar(255) and a daily
  PlayNotificationCleanupService purges records older than 30 days (wired into the health check).
- Webhook auth boundary extracted to an injectable IPlayPushTokenValidator (Google OIDC verification
  moved out of the controller), enabling full coverage: controller 401/200 wiring, the validator's
  fail-closed paths, and an end-to-end /play/rtdn integration test (envelope -> verify -> cancel).

Refs thomasluizon/orbit-ui-mobile#132

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

All deferred follow-ups implemented — d26741d

Everything previously deferred is now done on this repo:

  • Verify TOCTOU → a PlayPurchaseToken unique-index conflict now returns PLAY_PURCHASE_ACCOUNT_MISMATCH instead of a 500 (ownership re-checked in the catch; no Npgsql leak into the Application layer).
  • RTDN defense-in-depth → a notification whose verified ObfuscatedAccountId does not match the resolved user is skipped (the token→user binding is re-asserted, not trusted).
  • Dedup bounded + prunedProcessedPlayNotification.MessageId is now varchar(255); a new daily PlayNotificationCleanupService purges rows older than 30 days, wired into the background-service health check.
  • OIDC seam + tests → the webhook auth boundary moved out of the controller into an injectable IPlayPushTokenValidator (no crypto in the controller). New coverage: controller 401/200 wiring, validator fail-closed unit tests, and a full /play/rtdn integration test (envelope → verify → cancel → status).

Validation: build clean · Application 1783 · Domain 350 · Infrastructure 983 · Play integration 4/4 · ef migrations has-pending-model-changes → none.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Play Billing integration lands cleanly. The verify + RTDN pair forms a solid, fail-closed pair: the verify flow enforces strict account binding (obfuscated GUID must match the JWT user, else the request is rejected), while the RTDN re-verifies authoritative state from Google rather than trusting the push payload. A few things I specifically checked and found correct:

  • RTDN auth: [AllowAnonymous] is intentional — the JWT middleware can't validate Google's OIDC tokens, so IPlayPushTokenValidator takes over. The validator checks signature, audience, email, and EmailVerified, and the implementation falls closed on any InvalidJwtException.
  • Idempotency race: The optimistic pre-check + atomic commit + DbUpdateException catch on duplicate MessageId is the right pattern. If two concurrent deliveries race past the pre-check, the unique index catches them and the losing thread returns Result.Success() correctly.
  • Stripe → Play entitlement hand-off: CancelStripeSubscription and CancelPlaySubscription are source-aware — cancelling one channel can't accidentally wipe the other. ClearEntitlement is only called when the cancelled source matches the current SubscriptionSource. The "Stripe covers later period" shortcut in the verify handler is also correct: it links the token without downgrading the Stripe period.
  • Migration backfill: WHERE "StripeSubscriptionId" IS NOT NULL correctly identifies currently-active Stripe users (cancelled subs null the ID via CancelSubscription), back-filling them to source = 0 (Stripe). The unique filtered index on PlayPurchaseToken is the right constraint for TOCTOU protection.
  • API contract: Both new response fields (Source on SubscriptionStatusResponse and ProfileResponse) are additive nullable strings — no breaking change for existing mobile/web clients.

@thomasluizon
thomasluizon merged commit 573749d into main Jun 8, 2026
4 checks passed
@thomasluizon
thomasluizon deleted the feature/play-billing branch June 8, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant