Skip to content
Closed
7 changes: 6 additions & 1 deletion assets/js/sso.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

const denied = wu_read_cookie('wu_sso_denied');

const checkout_form = document.querySelector(
'#wrapper-field-checkout, .wu-checkout, .wu-checkout-form'
);
const checkout_url = /\/register\/?$/.test(window.location.pathname);

document.head.insertAdjacentHTML('beforeend', `
<style>
@keyframes fade_in {
Expand Down Expand Up @@ -59,7 +64,7 @@
</style>
`);

if (! o.is_user_logged_in && ! denied) {
if (! o.is_user_logged_in && ! denied && ! checkout_form && ! checkout_url) {

const s = document.getElementsByTagName('script')[ 0 ];

Expand Down
101 changes: 84 additions & 17 deletions inc/sso/class-sso.php
Original file line number Diff line number Diff line change
Expand Up @@ -977,13 +977,87 @@ public function handle_broker($response_type = 'redirect'): void {

// Attach through redirect if the client isn't attached yet.
if ( ! $broker->isAttached()) {
$sso_path = $this->get_url_path();

/*
* Determine the page we ultimately want the user to land on
* after the magic-link round-trip.
*
* The JSONP must-redirect fallback navigates the top-level
* browser to `<broker>/sso?return_url=<original_page>`, so on
* that second hit `get_current_url()` is the /sso URL itself
* — using it would feed `/sso?...` back into return_url and
* the main site would echo a longer `/sso?return_url=/sso?...`
* URL on each iteration, producing a `ERR_TOO_MANY_REDIRECTS`
* (the URL grows on every hop, never converges).
*
* Prefer the explicit `return_url` query param when present
* (set by sso.js on the must-redirect branch); only fall back
* to `get_current_url()` on a direct first hit. Guard the
* value against pointing at the /sso path itself so a
* stray/legacy URL can't restart the same loop.
*/
$requested_return = (string) $this->input('return_url', '');

if ( '' === $requested_return ) {
$return_url = $this->get_current_url();
} else {
$return_url = $requested_return;
}

$return_path = (string) wp_parse_url($return_url, PHP_URL_PATH);

if ( '' === $return_path || preg_match("#/{$sso_path}(-grant)?/?$#", $return_path) ) {
// Refuses to round-trip the /sso endpoint itself; fall back to subsite home.
$return_url = home_url('/');
}

/*
* Front-end auto-SSO target: send the user to the main site's
* /sso endpoint with a return_url + redirect_to. handle_server()
* on the main site checks the main-site auth cookie (first-party
* at that point — no third-party cookie restrictions apply) and,
* if logged in, issues an HMAC-signed `wu_sso_token` magic-link
* back to this subsite. handle_cookie_less_sso_token() consumes
* it on init and sets the broker-side auth cookie.
*
* The legacy `$broker->getAttachUrl()` returned a /sso-grant URL
* whose handler (`wu_sso_handle_sso_grant_grant`) is unreachable
* under the cookie-less rework introduced in #1084, so the old
* attach handshake silently fell through to a WordPress 404 and
* SSO never completed. Using /sso directly hits the cookie-less
* server path and works in any browser that allows the main-site
* first-party auth cookie.
*
* Pass `redirect_to` explicitly so main's `get_sso_redirect_to()`
* does not append `/wp/wp-admin/` (its fallback when only a
* cross-domain return_url is supplied), which would land the
* user on the subsite admin instead of the page they were
* actually viewing.
*/
$main_sso_url = add_query_arg(
[
$sso_path => 'login',
'return_url' => $return_url,
'redirect_to' => $return_url,
],
get_home_url(wu_get_main_site_id(), $sso_path)
);

/*
* For JSONP requests (initiated by a <script> tag), we must NOT
* redirect — the browser follows 302s transparently for script
* tags, but the final response from the server will be a redirect
* (not JavaScript), so the wu.sso() callback never fires.
* Instead, return a JSONP error so the JS can handle it gracefully
* and set the wu_sso_denied cookie to prevent further attempts.
* For JSONP requests (initiated by a <script> tag) we cannot
* 302 to HTML — the browser follows it transparently but the
* final response is HTML, not JavaScript, so the wu.sso()
* callback never fires. Return `verify: must-redirect` so the
* already-existing sso.js branch performs a top-level
* navigation (which re-enters handle_broker via the non-JSONP
* path below and lands on $main_sso_url).
*
* If the user is already logged out everywhere, the resulting
* /sso request on the main site falls through to a plain login
* page render. handle_login_redirect + login_form_defaults then
* carry the return_url through the login form so they end back
* on this subsite when they sign in.
*/
if ('jsonp' === $response_type) {
header('Content-Type: application/javascript; charset=utf-8');
Expand All @@ -992,8 +1066,9 @@ public function handle_broker($response_type = 'redirect'): void {
'wu.sso(%s, %d);',
wp_json_encode(
[
'code' => 0,
'message' => 'Broker not attached',
'code' => 200,
'verify' => 'must-redirect',
'return_url' => $return_url,
]
),
200
Expand All @@ -1004,15 +1079,7 @@ public function handle_broker($response_type = 'redirect'): void {
exit;
}

$return_url = $this->get_current_url();

$attach_url = $broker->getAttachUrl(
[
'return_url' => $return_url,
]
);

wp_safe_redirect($attach_url, 302, 'WP-Ultimo-SSO');
wp_safe_redirect($main_sso_url, 302, 'WP-Ultimo-SSO');

exit();
}
Expand Down
17 changes: 16 additions & 1 deletion sunrise.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
// phpcs:ignoreFile Squiz.Commenting.FileComment -- Legacy sunrise wrapper format is intentionally preserved.
// WP Ultimo Starts #
/**
* Ultimate Multisite Sunrise
Expand Down Expand Up @@ -37,14 +38,28 @@
? WPMU_PLUGIN_DIR . '/ultimate-multisite/inc/class-sunrise.php'
: WP_CONTENT_DIR . '/mu-plugins/ultimate-multisite/inc/class-sunrise.php';

$wu_sunrise_candidates = [$wu_sunrise, $wu_mu_sunrise];

if (defined('WP_PLUGIN_DIR')) {
$wu_dynamic_sunrise_files = glob(WP_PLUGIN_DIR . '/*/inc/class-sunrise.php');

if (is_array($wu_dynamic_sunrise_files)) {
foreach ($wu_dynamic_sunrise_files as $wu_dynamic_sunrise_file) {
if (file_exists(dirname(dirname($wu_dynamic_sunrise_file)) . '/ultimate-multisite.php')) {
$wu_sunrise_candidates[] = $wu_dynamic_sunrise_file;
}
}
}
}

/**
* We search for the sunrise class file
* in the plugins and mu-plugins folders.
*
* @since 2.0.0.3 Sunrise Version.
*/

foreach ([$wu_sunrise, $wu_mu_sunrise] as $wu_sunrise_file) {
foreach (array_unique($wu_sunrise_candidates) as $wu_sunrise_file) {
if (file_exists($wu_sunrise_file)) {
if ($wu_sunrise_file === $wu_mu_sunrise) {

Expand Down
29 changes: 27 additions & 2 deletions tests/e2e/cypress/fixtures/setup-checkout-form.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,33 @@
);

if ( $existing ) {
$form = $existing[0];
$page_id = wu_get_setting('default_registration_page', 0);
$form = $existing[0];
$page = get_page_by_path('register');

if ( $page ) {
$page_id = $page->ID;

wp_update_post(
[
'ID' => $page_id,
'post_content' => '[wu_checkout slug="main-form"]',
]
);
} else {
$page_id = wp_insert_post(
[
'post_name' => 'register',
'post_title' => 'Register',
'post_content' => '[wu_checkout slug="main-form"]',
'post_status' => 'publish',
'post_type' => 'page',
'post_author' => 1,
]
);
}

wu_save_setting('default_registration_page', $page_id);

echo 'form:' . esc_html($form->get_id()) . ',page:' . esc_html($page_id);
return;
}
Expand Down
89 changes: 84 additions & 5 deletions tests/e2e/cypress/fixtures/setup-sso-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,20 @@
$port = ':' . $m[1];
}

$mapped_domain = '127.0.0.1' . $port;
$mapped_host = '127.0.0.1';
$mapped_domain = $mapped_host . $port;

// 1. Create a subsite for SSO testing (or reuse if it already exists).
$existing = get_blog_id_from_url($network_domain, '/sso-test-site/');
$existing_sites = get_sites(
[
'domain' => $mapped_host,
'path' => '/',
'number' => 1,
'fields' => 'ids',
]
);

$existing = $existing_sites ? (int) $existing_sites[0] : get_blog_id_from_url($network_domain, '/sso-test-site/');

if ($existing) {
$site_id = $existing;
Expand All @@ -48,14 +58,40 @@
}
}

// 2. Insert domain mapping for 127.0.0.1:PORT directly into the DB.
// The Domain model's validation rejects IP addresses, so we bypass it.
/*
* wp-env receives browser requests for 127.0.0.1:PORT, but WordPress core's
* multisite bootstrap strips the non-standard port before `pre_get_site_by_path`
* runs. Store the test subsite as a native 127.0.0.1 root-domain site as well
* as a mapped domain so the request resolves to the subsite before core's
* unknown-domain redirect sends it back to the main localhost site.
*/
$wpdb->update(
$wpdb->blogs,
[
'domain' => $mapped_host,
'path' => '/',
],
['blog_id' => $site_id],
['%s', '%s'],
['%d']
);

clean_blog_cache($site_id);

update_blog_option($site_id, 'home', 'http://' . $mapped_domain);
update_blog_option($site_id, 'siteurl', 'http://' . $mapped_domain);

/*
* 2. Insert domain mapping for 127.0.0.1:PORT directly into the DB.
* The Domain model's validation rejects IP addresses, so we bypass it.
*/
$table = $wpdb->base_prefix . 'wu_domain_mappings';
$now = current_time('mysql');

// Check if the mapping already exists (look for both with and without port).
$existing_domain = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is built from the trusted WP database prefix.
"SELECT id FROM {$table} WHERE domain IN (%s, %s) AND blog_id = %d LIMIT 1",
$mapped_domain,
'127.0.0.1',
Expand All @@ -67,7 +103,11 @@
// Update existing record to ensure domain includes port.
$wpdb->update(
$table,
['domain' => $mapped_domain, 'active' => 1, 'stage' => 'done'],
[
'domain' => $mapped_domain,
'active' => 1,
'stage' => 'done',
],
['id' => $existing_domain],
['%s', '%d', '%s'],
['%d']
Expand Down Expand Up @@ -100,6 +140,45 @@
$domain_id = $wpdb->insert_id;
}

if ($port) {
$bare_domain_id = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is built from the trusted WP database prefix.
"SELECT id FROM {$table} WHERE domain = %s AND blog_id = %d LIMIT 1",
'127.0.0.1',
$site_id
)
);

if ($bare_domain_id) {
$wpdb->update(
$table,
[
'active' => 1,
'stage' => 'done',
],
['id' => $bare_domain_id],
['%d', '%s'],
['%d']
);
} else {
$wpdb->insert(
$table,
[
'blog_id' => $site_id,
'domain' => '127.0.0.1',
'active' => 1,
'primary_domain' => 0,
'secure' => 0,
'stage' => 'done',
'date_created' => $now,
'date_modified' => $now,
],
['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%s']
);
}
}

// Also set the wu_dmtable property so get_by_domain() can find it.
if (empty($wpdb->wu_dmtable)) {
$wpdb->wu_dmtable = $table;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ describe("Manual Gateway Checkout Flow", () => {
path: `manualsite${timestamp}`,
};

before(() => {
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-checkout-form.php");
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-gateway.php");
});

it("Should complete the UM checkout form with manual gateway", {
retries: 0,
}, () => {
Expand Down
Loading
Loading