From 2586aefd5fb7632fab049454f3f92debf46b6c7a Mon Sep 17 00:00:00 2001
From: David Stone
Date: Wed, 16 Sep 2026 17:08:13 -0600
Subject: [PATCH 1/2] fix(sso): authenticate cross-domain payment links
---
inc/models/class-payment.php | 6 +-
inc/sso/class-magic-link.php | 133 ++++++++++++++++++++++--
inc/ui/class-checkout-element.php | 13 +--
tests/WP_Ultimo/SSO/Magic_Link_Test.php | 129 +++++++++++++++++++++++
views/dashboard-widgets/invoices.php | 8 +-
5 files changed, 271 insertions(+), 18 deletions(-)
diff --git a/inc/models/class-payment.php b/inc/models/class-payment.php
index 082168c53..539df7d69 100644
--- a/inc/models/class-payment.php
+++ b/inc/models/class-payment.php
@@ -800,7 +800,11 @@ public function get_payment_url() {
$args['checkout_form'] = 'wu-pay-invoice';
}
- return add_query_arg($args, wu_get_registration_url());
+ $payment_url = add_query_arg($args, wu_get_registration_url());
+
+ $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance()->generate_payment_magic_link($this, $payment_url);
+
+ return $magic_link ?: $payment_url;
}
/**
diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php
index f5a9eded3..7e960a48f 100644
--- a/inc/sso/class-magic-link.php
+++ b/inc/sso/class-magic-link.php
@@ -152,6 +152,63 @@ public function generate_magic_link($user_id, $site_id, $redirect_to = '') {
return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
}
+ /**
+ * Generate a magic link for a customer to pay an outstanding payment.
+ *
+ * Payment links target the network main site, where customers are not
+ * necessarily site members. Access is therefore bound to payment ownership
+ * rather than normal site membership.
+ *
+ * @since 2.16.2
+ *
+ * @param \WP_Ultimo\Models\Payment $payment Payment to be paid.
+ * @param string $redirect_to Exact payment checkout URL.
+ * @return string|false The magic link URL or false on failure.
+ */
+ public function generate_payment_magic_link($payment, $redirect_to) {
+
+ if ( ! $this->is_enabled() || ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) {
+ return false;
+ }
+
+ $user_id = get_current_user_id();
+
+ if ( ! $user_id || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) {
+ return false;
+ }
+
+ $target_host = wp_parse_url($redirect_to, PHP_URL_HOST);
+ $current_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
+
+ if ( ! $target_host || $target_host === $current_host ) {
+ return false;
+ }
+
+ $site_id = wu_get_main_site_id();
+ $token = $this->generate_token();
+
+ $token_data = array(
+ 'user_id' => $user_id,
+ 'site_id' => $site_id,
+ 'redirect_to' => $redirect_to,
+ 'created_at' => time(),
+ 'user_agent' => $this->get_user_agent(),
+ 'ip_address' => $this->get_client_ip(),
+ 'purpose' => 'payment',
+ 'payment_id' => $payment->get_id(),
+ );
+
+ $transient_key = self::TRANSIENT_PREFIX . $token;
+
+ wu_switch_blog_and_run(
+ fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION)
+ );
+
+ $magic_link = add_query_arg(self::TOKEN_QUERY_ARG, $token, $redirect_to);
+
+ return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
+ }
+
/**
* Generate a magic link for cross-network authentication.
*
@@ -246,6 +303,41 @@ protected function verify_user_site_access($user_id, $site_id) {
return false;
}
+ /**
+ * Verify that a user owns a payable payment and its fixed checkout URL.
+ *
+ * @since 2.16.2
+ *
+ * @param \WP_Ultimo\Models\Payment $payment Payment being accessed.
+ * @param int $user_id User ID to authenticate.
+ * @param string $redirect_to Payment checkout URL.
+ * @return bool True when access is allowed, false otherwise.
+ */
+ protected function verify_payment_access($payment, $user_id, $redirect_to) {
+
+ if ( ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) {
+ return false;
+ }
+
+ $customer = $payment->get_customer();
+
+ if ( ! $customer || (int) $customer->get_user_id() !== (int) $user_id ) {
+ return false;
+ }
+
+ $args = array(
+ 'payment' => $payment->get_hash(),
+ );
+
+ if ( ! $payment->get_membership_id() ) {
+ $args['checkout_form'] = 'wu-pay-invoice';
+ }
+
+ $expected_url = add_query_arg($args, wu_get_registration_url());
+
+ return $expected_url === $redirect_to;
+ }
+
/**
* Handle magic link token verification and login.
*
@@ -280,8 +372,15 @@ public function handle_magic_link(): void {
return;
}
- // Verify user still has access to the site.
- if ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+ if ( 'payment' === ($token_data['purpose'] ?? '') ) {
+ $payment = wu_get_payment((int) ($token_data['payment_id'] ?? 0));
+
+ if ( ! $payment || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) {
+ $this->handle_invalid_token('User does not have access to this payment.');
+ return;
+ }
+ } elseif ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+ // Verify user still has access to the site.
$this->handle_invalid_token('User does not have access to this site.');
return;
}
@@ -324,9 +423,23 @@ public function handle_magic_link(): void {
protected function verify_and_consume_token($token) {
$transient_key = self::TRANSIENT_PREFIX . $token;
+ $claim_key = $transient_key . '_claim';
$token_data = wu_switch_blog_and_run(
- fn() => get_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ if ( ! add_option($claim_key, time(), '', false) ) {
+ return false;
+ }
+
+ $token_data = get_transient($transient_key);
+
+ if ( false === $token_data ) {
+ delete_option($claim_key);
+ }
+
+ return $token_data;
+ }
);
if ( false === $token_data ) {
@@ -338,14 +451,22 @@ protected function verify_and_consume_token($token) {
if ( ! $this->verify_security_context($token_data) ) {
wu_log_add('magic-link', sprintf('Security context mismatch for token: %s', $token));
wu_switch_blog_and_run(
- fn() => delete_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ delete_transient($transient_key);
+ delete_option($claim_key);
+ }
);
return false;
}
- // Delete the transient to ensure one-time use.
+ // Delete the token before releasing its atomic consumption claim.
wu_switch_blog_and_run(
- fn() => delete_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ delete_transient($transient_key);
+ delete_option($claim_key);
+ }
);
// Log successful authentication for audit trail.
diff --git a/inc/ui/class-checkout-element.php b/inc/ui/class-checkout-element.php
index 0d22c8c60..64bdf52e8 100644
--- a/inc/ui/class-checkout-element.php
+++ b/inc/ui/class-checkout-element.php
@@ -1009,15 +1009,12 @@ public function output_form($atts, $content = null) {
// Translators: Placeholder receives the customer display name
printf(esc_html__('Hi %s. You have a pending payment for your membership!', 'ultimate-multisite'), esc_html($customer->get_display_name()));
- $payment_url = add_query_arg(
- [
- 'payment' => $pending_payment->get_hash(),
- ],
- wu_get_registration_url()
- );
+ $payment_url = $pending_payment->get_payment_url();
- // Translators: The link to registration url with payment hash
- echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
+ if ($payment_url) {
+ // translators: %s is the payment URL.
+ echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
+ }
echo '
';
diff --git a/tests/WP_Ultimo/SSO/Magic_Link_Test.php b/tests/WP_Ultimo/SSO/Magic_Link_Test.php
index 802af35be..4bef13798 100644
--- a/tests/WP_Ultimo/SSO/Magic_Link_Test.php
+++ b/tests/WP_Ultimo/SSO/Magic_Link_Test.php
@@ -17,6 +17,34 @@ private function get_instance() {
return Magic_Link::get_instance();
}
+ /**
+ * Create a payable payment owned by a new customer.
+ *
+ * @return array{customer: \WP_Ultimo\Models\Customer, payment: \WP_Ultimo\Models\Payment, user_id: int}
+ */
+ private function create_payable_payment() {
+
+ $user_id = self::factory()->user->create();
+ $customer = wu_create_customer(['user_id' => $user_id]);
+
+ $this->assertNotWPError($customer);
+
+ $payment = wu_create_payment(
+ [
+ 'customer_id' => $customer->get_id(),
+ 'currency' => 'USD',
+ 'subtotal' => 25,
+ 'total' => 25,
+ 'status' => 'pending',
+ 'gateway' => 'manual',
+ ]
+ );
+
+ $this->assertNotWPError($payment);
+
+ return compact('customer', 'payment', 'user_id');
+ }
+
public function set_up() {
parent::set_up();
@@ -221,6 +249,107 @@ public function test_generate_magic_link_invalid_user() {
$this->assertFalse($result);
}
+ /**
+ * Test payment magic links bind a customer to a single payment checkout URL.
+ */
+ public function test_generate_payment_magic_link_for_owner() {
+
+ $objects = $this->create_payable_payment();
+ $payment = $objects['payment'];
+ $redirect_to = add_query_arg(
+ [
+ 'payment' => $payment->get_hash(),
+ 'checkout_form' => 'wu-pay-invoice',
+ ],
+ wu_get_registration_url()
+ );
+
+ wp_set_current_user($objects['user_id']);
+
+ $site_id = self::factory()->blog->create();
+ switch_to_blog($site_id);
+ update_option('home', 'https://customer.example.test');
+
+ try {
+ $magic_link = $payment->get_payment_url();
+ } finally {
+ restore_current_blog();
+ }
+
+ $this->assertIsString($magic_link);
+ $this->assertStringContainsString($payment->get_hash(), $magic_link);
+ $this->assertSame($redirect_to, remove_query_arg(Magic_Link::TOKEN_QUERY_ARG, $magic_link));
+
+ parse_str((string) wp_parse_url($magic_link, PHP_URL_QUERY), $query_args);
+ $this->assertArrayHasKey(Magic_Link::TOKEN_QUERY_ARG, $query_args);
+
+ $token_data = wu_switch_blog_and_run(
+ fn() => get_transient(Magic_Link::TRANSIENT_PREFIX . $query_args[ Magic_Link::TOKEN_QUERY_ARG ])
+ );
+
+ $this->assertSame('payment', $token_data['purpose']);
+ $this->assertSame($payment->get_id(), $token_data['payment_id']);
+ $this->assertSame($redirect_to, $token_data['redirect_to']);
+
+ $consume = new \ReflectionMethod($this->get_instance(), 'verify_and_consume_token');
+
+ if (PHP_VERSION_ID < 80100) {
+ $consume->setAccessible(true);
+ }
+
+ $this->assertIsArray($consume->invoke($this->get_instance(), $query_args[ Magic_Link::TOKEN_QUERY_ARG ]));
+ $this->assertFalse($consume->invoke($this->get_instance(), $query_args[ Magic_Link::TOKEN_QUERY_ARG ]));
+ }
+
+ /**
+ * Test payment magic links reject users who do not own the payment.
+ */
+ public function test_generate_payment_magic_link_rejects_non_owner() {
+
+ $objects = $this->create_payable_payment();
+ $payment = $objects['payment'];
+
+ wp_set_current_user(self::factory()->user->create());
+
+ $this->assertFalse(
+ $this->get_instance()->generate_payment_magic_link($payment, $payment->get_payment_url())
+ );
+ }
+
+ /**
+ * Test payment access rejects a modified checkout URL.
+ */
+ public function test_verify_payment_access_rejects_modified_redirect() {
+
+ $objects = $this->create_payable_payment();
+ $payment = $objects['payment'];
+ $redirect_to = add_query_arg('payment', $payment->get_hash(), wu_get_registration_url());
+ $redirect_to = add_query_arg('redirect_to', 'https://attacker.example.test', $redirect_to);
+ $ref = new \ReflectionMethod($this->get_instance(), 'verify_payment_access');
+
+ if (PHP_VERSION_ID < 80100) {
+ $ref->setAccessible(true);
+ }
+
+ $this->assertFalse($ref->invoke($this->get_instance(), $payment, $objects['user_id'], $redirect_to));
+ }
+
+ /**
+ * Test payment magic links reject payments that can no longer be paid.
+ */
+ public function test_generate_payment_magic_link_rejects_non_payable_payment() {
+
+ $objects = $this->create_payable_payment();
+ $payment = $objects['payment'];
+ $payment->set_status('completed');
+
+ wp_set_current_user($objects['user_id']);
+
+ $this->assertFalse(
+ $this->get_instance()->generate_payment_magic_link($payment, add_query_arg('payment', $payment->get_hash(), wu_get_registration_url()))
+ );
+ }
+
/**
* Test handle_magic_link bails with no token.
*/
diff --git a/views/dashboard-widgets/invoices.php b/views/dashboard-widgets/invoices.php
index 1ee6282ed..da69491ad 100644
--- a/views/dashboard-widgets/invoices.php
+++ b/views/dashboard-widgets/invoices.php
@@ -7,7 +7,7 @@
defined('ABSPATH') || exit;
?>
-