Tighten self-hosted purchase return binding

This commit is contained in:
rcourtman 2026-04-08 20:21:36 +01:00
parent 51b076a592
commit 9e83d0862d
6 changed files with 117 additions and 11 deletions

View file

@ -584,7 +584,16 @@ resolution must stamp `resolved_at`, the portal-facing lifecycle must stay
derived from the owned handoff plus the private checkout intent
(`created`, `resolved`, `checkout_started`, `completed`), and completed
handoffs must refuse browser checkout replay instead of silently reopening
commercial state. That same owned contract also retires the old compatibility
commercial state. The owned handoff row is also the canonical binding record
for product-originated self-hosted checkout: it must persist the signed
`purchase_return_jti`, the bound Stripe `session_id`, and the timestamps that
prove resolve, checkout-start, and completion. Stripe success must return
that same `portal_handoff_id` into Pulse's activation callback, and Pulse
must compare both `portal_handoff_id` and `purchase_return_jti` against the
commercial checkout-session result before redeeming the activation key, so
browser form/query state and Stripe metadata alone never become the source of
truth for a completed self-hosted upgrade. That same owned contract also
retires the old compatibility
bootstrap surfaces: Pulse must not expose a separate public
`GET /auth/license-purchase-handoff` resolver, and the commercial server must
not expose a direct browser bootstrap through `GET /v1/checkout/intent` once

View file

@ -489,6 +489,14 @@ lifecycle must stay derived from the owned portal handoff plus the bound
checkout intent (`created`, `resolved`, `checkout_started`, `completed`) so
Pulse Account can distinguish a fresh upgrade bootstrap from a resumed or
already-completed checkout without reviving browser-owned commercial state.
That same owned handoff row is now also the canonical self-hosted purchase
binding record: it must persist the signed `purchase_return_jti`, the bound
Stripe `session_id`, and the lifecycle timestamps that prove when checkout was
resolved, started, and completed. Stripe success must carry that same
`portal_handoff_id` back into Pulse's activation callback, and Pulse must
cross-check both `portal_handoff_id` and `purchase_return_jti` against the
commercial session result before local activation so the browser no longer
trusts Stripe metadata or local form state alone for return integrity.
Stripe success now lands on Pulse's public
`frontend-modern/src/utils/pricingHandoff.ts` and
`frontend-modern/src/pages/PricingHandoff.tsx` may only hand operators into

View file

@ -48,6 +48,19 @@ func issuePurchaseReturnToken(t *testing.T, handler *LicenseHandlers, orgID, fea
return token
}
func purchaseReturnJTIFromToken(t *testing.T, handler *LicenseHandlers, token string) string {
t.Helper()
signingKey, err := handler.purchaseReturnSigningKey()
if err != nil {
t.Fatalf("purchaseReturnSigningKey: %v", err)
}
claims, err := verifyPurchaseReturnTokenFromLicensing(token, signingKey, "pulse.example.com", time.Now().UTC())
if err != nil {
t.Fatalf("verifyPurchaseReturnTokenFromLicensing: %v", err)
}
return strings.TrimSpace(claims.ID)
}
type licenseFeaturesResponseDTO struct {
LicenseStatus string `json:"license_status"`
Features map[string]bool `json:"features"`
@ -812,7 +825,7 @@ func TestHandleCheckoutActivation_GETRendersAutoSubmitBridge(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet,
"https://pulse.example.com"+licensePurchaseActivationPath+
"?session_id=cs_success&purchase_return_token="+url.QueryEscape(returnToken),
"?session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken),
nil,
)
rec := httptest.NewRecorder()
@ -835,6 +848,9 @@ func TestHandleCheckoutActivation_GETRendersAutoSubmitBridge(t *testing.T) {
if !strings.Contains(body, `name="purchase_return_token" value="`+returnToken+`"`) {
t.Fatalf("body = %q, want purchase_return_token hidden field", body)
}
if !strings.Contains(body, `name="portal_handoff_id" value="cph_success"`) {
t.Fatalf("body = %q, want portal_handoff_id hidden field", body)
}
if !strings.Contains(body, `name="feature" value="max_monitored_systems"`) {
t.Fatalf("body = %q, want feature hidden field derived from claims", body)
}
@ -846,6 +862,10 @@ func TestHandleCheckoutActivation_GETRendersAutoSubmitBridge(t *testing.T) {
func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
handler := createTestHandler(t)
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: "lic_checkout_success",
Tier: "pro_plus",
@ -872,7 +892,7 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge
t.Fatalf("session_id = %q, want cs_success", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"fulfilled","activation_key":"ppk_live_checkout_activation"}`))
_, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_success","purchase_return_jti":"` + purchaseReturnJTI + `","activation_key":"ppk_live_checkout_activation"}`))
case "/v1/activate":
if r.Method != http.MethodPost {
t.Fatalf("activate method = %s, want POST", r.Method)
@ -904,12 +924,10 @@ func TestHandleCheckoutActivation_RedeemsCompletedCheckoutAndWritesSuccessBridge
defer server.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
handler := createTestHandler(t)
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_success&purchase_return_token="+url.QueryEscape(returnToken)),
strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
@ -977,7 +995,7 @@ func TestHandleCheckoutActivation_RendersFailurePageWhenCheckoutIsNotFulfilled(t
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_pending&purchase_return_token="+url.QueryEscape(returnToken)),
strings.NewReader("session_id=cs_pending&portal_handoff_id=cph_pending&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
@ -1011,7 +1029,7 @@ func TestHandleCheckoutActivation_RejectsPurchaseReturnBindingMismatch(t *testin
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_success&purchase_return_token="+url.QueryEscape(returnToken)),
strings.NewReader("session_id=cs_success&portal_handoff_id=cph_success&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
@ -1029,6 +1047,41 @@ func TestHandleCheckoutActivation_RejectsPurchaseReturnBindingMismatch(t *testin
}
}
func TestHandleCheckoutActivation_RejectsPortalHandoffBindingMismatch(t *testing.T) {
handler := createTestHandler(t)
returnToken := issuePurchaseReturnToken(t, handler, "default", "max_monitored_systems")
purchaseReturnJTI := purchaseReturnJTIFromToken(t, handler, returnToken)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/checkout/session" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"fulfilled","portal_handoff_id":"cph_wrong","purchase_return_jti":"` + purchaseReturnJTI + `","activation_key":"ppk_live_should_not_activate"}`))
}))
defer server.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
req := httptest.NewRequest(
http.MethodPost,
"https://pulse.example.com"+licensePurchaseActivationPath,
strings.NewReader("session_id=cs_success&portal_handoff_id=cph_expected&purchase_return_token="+url.QueryEscape(returnToken)),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.HandleCheckoutActivation(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusConflict, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "did not match the completed upgrade flow") {
t.Fatalf("body = %q, want binding mismatch message", rec.Body.String())
}
if handler.Service(context.Background()).IsActivated() {
t.Fatal("expected mismatched portal handoff to leave the local license inactive")
}
}
// ========================================
// userFriendlyActivationError tests
// ========================================

View file

@ -408,27 +408,34 @@ func writeLicensePurchaseActivationSuccessPage(w http.ResponseWriter, feature st
func writeLicensePurchaseActivationContinuePage(
w http.ResponseWriter,
sessionID string,
portalHandoffID string,
feature string,
returnToken string,
) {
escapedFeature := html.EscapeString(feature)
escapedReturnToken := html.EscapeString(returnToken)
escapedSessionID := html.EscapeString(sessionID)
escapedPortalHandoffID := html.EscapeString(strings.TrimSpace(portalHandoffID))
featureInput := ""
if strings.TrimSpace(feature) != "" {
featureInput = fmt.Sprintf("<input type=\"hidden\" name=\"feature\" value=\"%s\">", escapedFeature)
}
portalHandoffInput := ""
if strings.TrimSpace(portalHandoffID) != "" {
portalHandoffInput = fmt.Sprintf("<input type=\"hidden\" name=\"%s\" value=\"%s\">", pulseAccountPortalHandoffIDField, escapedPortalHandoffID)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(
w,
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Finalizing Pulse Pro upgrade</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Finalizing Pulse Pro upgrade</h1><p id=\"purchase-activation-continue-status\" style=\"margin-bottom:1rem\">Pulse is securely finalizing the completed checkout.</p><form id=\"purchase-activation-continue-form\" method=\"POST\" action=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\">%s<button type=\"submit\">Continue to Pulse Pro</button></form></main><script>(function(){var form=document.getElementById('purchase-activation-continue-form');var statusEl=document.getElementById('purchase-activation-continue-status');if(statusEl){statusEl.textContent='Pulse is finishing the upgrade and returning you to billing.';}if(form&&typeof form.submit==='function'){setTimeout(function(){form.submit();},50);}}());</script></body></html>",
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Finalizing Pulse Pro upgrade</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Finalizing Pulse Pro upgrade</h1><p id=\"purchase-activation-continue-status\" style=\"margin-bottom:1rem\">Pulse is securely finalizing the completed checkout.</p><form id=\"purchase-activation-continue-form\" method=\"POST\" action=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\">%s%s<button type=\"submit\">Continue to Pulse Pro</button></form></main><script>(function(){var form=document.getElementById('purchase-activation-continue-form');var statusEl=document.getElementById('purchase-activation-continue-status');if(statusEl){statusEl.textContent='Pulse is finishing the upgrade and returning you to billing.';}if(form&&typeof form.submit==='function'){setTimeout(function(){form.submit();},50);}}());</script></body></html>",
licensePurchaseActivationPath,
licensePurchaseSessionIDField,
escapedSessionID,
licensePurchaseReturnTokenField,
escapedReturnToken,
featureInput,
portalHandoffInput,
)
}
@ -1491,6 +1498,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
sessionID := strings.TrimSpace(r.URL.Query().Get(licensePurchaseSessionIDField))
portalHandoffID := strings.TrimSpace(r.URL.Query().Get(pulseAccountPortalHandoffIDField))
feature := strings.TrimSpace(r.URL.Query().Get("feature"))
returnToken := strings.TrimSpace(r.URL.Query().Get(licensePurchaseReturnTokenField))
if returnToken == "" || sessionID == "" {
@ -1506,7 +1514,7 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
if claims != nil && strings.TrimSpace(claims.Feature) != "" {
feature = strings.TrimSpace(claims.Feature)
}
writeLicensePurchaseActivationContinuePage(w, sessionID, feature, returnToken)
writeLicensePurchaseActivationContinuePage(w, sessionID, portalHandoffID, feature, returnToken)
return
}
if r.Method != http.MethodPost {
@ -1519,6 +1527,7 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
}
sessionID := strings.TrimSpace(r.FormValue(licensePurchaseSessionIDField))
portalHandoffID := strings.TrimSpace(r.FormValue(pulseAccountPortalHandoffIDField))
feature := strings.TrimSpace(r.FormValue("feature"))
returnToken := strings.TrimSpace(r.FormValue(licensePurchaseReturnTokenField))
if returnToken == "" {
@ -1626,6 +1635,27 @@ func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *htt
if checkoutResult != nil {
resolvedPurchaseReturnJTI = strings.TrimSpace(checkoutResult.PurchaseReturnJTI)
}
resolvedPortalHandoffID := ""
if checkoutResult != nil {
resolvedPortalHandoffID = strings.TrimSpace(checkoutResult.PortalHandoffID)
}
if portalHandoffID != "" {
if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID {
clearReplay("portal_handoff_id_mismatch", nil)
log.Warn().
Str("checkout_session_id", sessionID).
Str("expected_portal_handoff_id", portalHandoffID).
Str("resolved_portal_handoff_id", resolvedPortalHandoffID).
Msg("Rejected checkout activation due to portal handoff binding mismatch")
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Pulse Pro billing.")
return
}
} else if resolvedPortalHandoffID != "" {
log.Warn().
Str("checkout_session_id", sessionID).
Str("resolved_portal_handoff_id", resolvedPortalHandoffID).
Msg("Purchase activation continued without browser portal_handoff_id state")
}
if expectedPurchaseReturnJTI == "" || resolvedPurchaseReturnJTI == "" || expectedPurchaseReturnJTI != resolvedPurchaseReturnJTI {
clearReplay("purchase_return_jti_mismatch", nil)
log.Warn().

View file

@ -28,6 +28,7 @@ type CheckoutSessionResult struct {
Message string `json:"message,omitempty"`
CheckoutStatus string `json:"checkout_status,omitempty"`
PaymentStatus string `json:"payment_status,omitempty"`
PortalHandoffID string `json:"portal_handoff_id,omitempty"`
PurchaseReturnJTI string `json:"purchase_return_jti,omitempty"`
LicenseID string `json:"license_id,omitempty"`
ActivationKey string `json:"activation_key,omitempty"`

View file

@ -220,7 +220,7 @@ test.describe("Self-hosted upgrade return flow", () => {
"<h1>Pulse Account</h1>" +
"<p>Checkout complete. Returning to Pulse Pro.</p>" +
`<script>setTimeout(function(){window.location.replace(${JSON.stringify(
`${PURCHASE_RETURN_URL}?session_id=cs_upgrade_return&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`,
`${PURCHASE_RETURN_URL}?session_id=cs_upgrade_return&portal_handoff_id=${PORTAL_HANDOFF_ID}&purchase_return_token=${encodeURIComponent(PURCHASE_RETURN_TOKEN)}`,
)});},150);</script>` +
"</body></html>",
});
@ -233,6 +233,9 @@ test.describe("Self-hosted upgrade return flow", () => {
expect(requestUrl.searchParams.get("session_id")).toBe(
"cs_upgrade_return",
);
expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(
PORTAL_HANDOFF_ID,
);
expect(requestUrl.searchParams.get("purchase_return_token")).toBe(
PURCHASE_RETURN_TOKEN,
);
@ -244,6 +247,7 @@ test.describe("Self-hosted upgrade return flow", () => {
"<h1>Finalizing Pulse Pro upgrade</h1>" +
`<form id="purchase-activation-continue-form" method="POST" action="${escapeAttribute(PURCHASE_RETURN_URL)}">` +
'<input type="hidden" name="session_id" value="cs_upgrade_return">' +
`<input type="hidden" name="portal_handoff_id" value="${escapeAttribute(PORTAL_HANDOFF_ID)}">` +
`<input type="hidden" name="purchase_return_token" value="${escapeAttribute(PURCHASE_RETURN_TOKEN)}">` +
"</form>" +
'<script>setTimeout(function(){document.getElementById("purchase-activation-continue-form")?.submit();},50);</script>' +
@ -255,6 +259,7 @@ test.describe("Self-hosted upgrade return flow", () => {
expect(request.method()).toBe("POST");
const formData = new URLSearchParams(request.postData() || "");
expect(formData.get("session_id")).toBe("cs_upgrade_return");
expect(formData.get("portal_handoff_id")).toBe(PORTAL_HANDOFF_ID);
expect(formData.get("purchase_return_token")).toBe(PURCHASE_RETURN_TOKEN);
await route.fulfill({
status: 200,