mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Tighten self-hosted purchase return binding
This commit is contained in:
parent
51b076a592
commit
9e83d0862d
6 changed files with 117 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue