Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
20.96% covered (danger)
20.96%
153 / 730
45.65% covered (danger)
45.65%
21 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
SSO
20.96% covered (danger)
20.96%
153 / 730
45.65% covered (danger)
45.65%
21 / 46
17828.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 get_instance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sync_sso_callables
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 sso_reminder_logout_wpcom
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 maybe_logout_user
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 xmlrpc_methods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 xmlrpc_user_disconnect
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 login_enqueue_scripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 login_body_class
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
12.33
 print_inline_admin_css
n/a
0 / 0
n/a
0 / 0
1
 enqueue_login_styles
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 register_settings
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
2
 render_require_two_step
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 validate_jetpack_sso_require_two_step
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 render_match_by_email
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 validate_jetpack_sso_match_by_email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 wants_to_login
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 login_init
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
156
 display_sso_login_form
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 save_cookies
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 login_form
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
90
 clear_cookies_after_login
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
42
 disconnect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 request_initial_nonce
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 validate_broker_url
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 is_broker_authorized
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 is_referrer_wpcom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_live_referrer_wpcom
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 get_broker_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_broker_auth_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 handle_login
0.00% covered (danger)
0.00%
0 / 170
0.00% covered (danger)
0.00%
0 / 1
1190
 profile_page_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_sso_button
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 build_sso_button_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_sso_url_or_die
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 get_sso_base_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 build_sso_url
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 build_reauth_and_sso_url
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 set_wpcom_user_id_meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user_by_wpcom_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_signed_user_token_for_wpcom_id
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
8.64
 verify_user_token
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 maybe_authorize_user_after_sso
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 store_wpcom_profile_cookies_on_logout
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 is_user_connected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_two_factor_session_meta
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * SSO feature. Entry point.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Connection\SSO\Force_2FA;
12use Automattic\Jetpack\Connection\SSO\Helpers;
13use Automattic\Jetpack\Connection\SSO\Notices;
14use Automattic\Jetpack\Connection\SSO\User_Admin;
15use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect;
16use Automattic\Jetpack\Constants;
17use Automattic\Jetpack\Roles;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Tracking;
21use Jetpack_IXR_Client;
22use WP_Error;
23use WP_User;
24use WP_User_Query;
25
26/**
27 * SSO feature main class.
28 */
29class SSO {
30    /**
31     * WordPress.com User information.
32     *
33     * @var false|object
34     */
35    private $user_data;
36
37    /**
38     * Automattic\Jetpack\Connection\SSO instance.
39     *
40     * @var \Automattic\Jetpack\Connection\SSO
41     */
42    public static $instance = null;
43
44    /**
45     * Stores the WP_User being authenticated via SSO so the
46     * attach_session_information callback can tag the session.
47     *
48     * @var WP_User|null
49     */
50    private static $sso_user_for_2fa = null;
51
52    /**
53     * Cookie name for the SSO broker authorization signal.
54     *
55     * Set when WP.com signals that a broker should be used for SSO. The cookie
56     * value is the SSO nonce, tying the signal to a specific authentication flow.
57     *
58     * @var string
59     */
60    const BROKER_COOKIE = 'jetpack_sso_broker';
61
62    /**
63     * Automattic\Jetpack\Connection\SSO constructor.
64     */
65    private function __construct() {
66
67        self::$instance = $this;
68
69        add_action( 'admin_init', array( $this, 'maybe_authorize_user_after_sso' ), 1 );
70        add_action( 'admin_init', array( $this, 'register_settings' ) );
71        add_action( 'login_init', array( $this, 'login_init' ) );
72        add_filter( 'jetpack_xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
73        add_action( 'init', array( $this, 'maybe_logout_user' ), 5 );
74        add_action( 'login_form_logout', array( $this, 'store_wpcom_profile_cookies_on_logout' ) );
75        add_action( 'jetpack_unlinked_user', array( Helpers::class, 'delete_connection_for_user' ) );
76
77        add_action( 'jetpack_site_before_disconnected', array( static::class, 'disconnect' ) );
78        add_action( 'wp_login', array( static::class, 'clear_cookies_after_login' ) );
79
80        // Adding this action so that on login_init, the action won't be sanitized out of the $action global.
81        add_action( 'login_form_jetpack-sso', '__return_true' );
82
83        add_filter( 'wp_login_errors', array( $this, 'sso_reminder_logout_wpcom' ) );
84
85        // Synchronize SSO options with WordPress.com.
86        add_filter( 'jetpack_sync_callable_whitelist', array( $this, 'sync_sso_callables' ), 10, 1 );
87
88        /**
89         * Filter to include Force 2FA feature.
90         *
91         * By default, `manage_options` users are forced when enable. The capability can be modified
92         * with the `jetpack_force_2fa_cap` filter.
93         *
94         * To enable the feature, add the following code:
95         * add_filter( 'jetpack_force_2fa', '__return_true' );
96         *
97         * @param bool $force_2fa Whether to force 2FA or not.
98         *
99         * @todo Provide a UI to enable/disable the feature.
100         *
101         * @since jetpack-12.7
102         * @module SSO
103         * @return bool
104         */
105        if (
106            ! class_exists( 'Automattic\Jetpack\Connection\SSO\Force_2FA', false )
107            && apply_filters( 'jetpack_force_2fa', false )
108        ) {
109            new Force_2FA();
110        }
111
112        /*
113         * Allow admins to invite new users to create a WordPress.com account
114         * as they are added to the site.
115         *
116         * This is a feature that is only available when the admin is connected to WordPress.com.
117         */
118        if (
119            ( new Manager() )->is_user_connected() &&
120            ! is_multisite() &&
121            /**
122             * Toggle the ability to invite new users to create a WordPress.com account.
123             *
124             * @module sso
125             *
126             * @since 2.7.2
127             *
128             * @param bool true Whether to allow admins to invite new users to create a WordPress.com account.
129             */
130            apply_filters( 'jetpack_sso_invite_new_users_wpcom', true )
131        ) {
132            new User_Admin();
133        }
134    }
135
136    /**
137     * Returns the single instance of the Automattic\Jetpack\Connection\SSO object
138     *
139     * @since jetpack-2.8
140     * @return \Automattic\Jetpack\Connection\SSO
141     */
142    public static function get_instance() {
143        if ( self::$instance !== null ) {
144            return self::$instance;
145        }
146
147        self::$instance = new SSO();
148        return self::$instance;
149    }
150
151    /**
152     * Add SSO callables to the sync whitelist.
153     *
154     * @since 2.8.1
155     *
156     * @param array $callables list of callables.
157     *
158     * @return array list of callables.
159     */
160    public function sync_sso_callables( $callables ) {
161        $sso_callables = array(
162            'sso_is_two_step_required'      => array( Helpers::class, 'is_two_step_required' ),
163            'sso_should_hide_login_form'    => array( Helpers::class, 'should_hide_login_form' ),
164            'sso_match_by_email'            => array( Helpers::class, 'match_by_email' ),
165            'sso_new_user_override'         => array( Helpers::class, 'new_user_override' ),
166            'sso_bypass_default_login_form' => array( Helpers::class, 'bypass_login_forward_wpcom' ),
167        );
168
169        return array_merge( $callables, $sso_callables );
170    }
171
172    /**
173     * Safety heads-up added to the logout messages when SSO is enabled.
174     * Some folks on a shared computer don't know that they need to log out of WordPress.com as well.
175     *
176     * @param WP_Error $errors WP_Error object.
177     */
178    public function sso_reminder_logout_wpcom( $errors ) {
179        if ( ( new Host() )->is_wpcom_platform() ) {
180            return $errors;
181        }
182
183        if ( ! empty( $errors->errors['loggedout'] ) ) {
184            $logout_message = wp_kses(
185                sprintf(
186                /* translators: %1$s is a link to the WordPress.com account settings page. */
187                    __( 'If you are on a shared computer, remember to also <a href="%1$s">log out of WordPress.com</a>.', 'jetpack-connection' ),
188                    'https://wordpress.com/me'
189                ),
190                array(
191                    'a' => array(
192                        'href' => array(),
193                    ),
194                )
195            );
196            $errors->add( 'jetpack-sso-show-logout', $logout_message, 'message' );
197        }
198        return $errors;
199    }
200
201    /**
202     * If jetpack_force_logout == 1 in current user meta the user will be forced
203     * to logout and reauthenticate with the site.
204     **/
205    public function maybe_logout_user() {
206        global $current_user;
207
208        if ( 1 === (int) $current_user->jetpack_force_logout ) {
209            delete_user_meta( $current_user->ID, 'jetpack_force_logout' );
210            Helpers::delete_connection_for_user( $current_user->ID );
211            wp_logout();
212            wp_safe_redirect( wp_login_url() );
213            exit( 0 );
214        }
215    }
216
217    /**
218     * Adds additional methods the WordPress xmlrpc API for handling SSO specific features
219     *
220     * @param array $methods API methods.
221     * @return array
222     **/
223    public function xmlrpc_methods( $methods ) {
224        $methods['jetpack.userDisconnect'] = array( $this, 'xmlrpc_user_disconnect' );
225        return $methods;
226    }
227
228    /**
229     * Marks a user's profile for disconnect from WordPress.com and forces a logout
230     * the next time the user visits the site.
231     *
232     * @param int $user_id User to disconnect from the site.
233     **/
234    public function xmlrpc_user_disconnect( $user_id ) {
235        $user = self::get_user_by_wpcom_id( $user_id );
236
237        if ( $user instanceof WP_User ) {
238            $user = wp_set_current_user( $user->ID );
239            update_user_meta( $user->ID, 'jetpack_force_logout', '1' );
240            Helpers::delete_connection_for_user( $user->ID );
241            return true;
242        }
243        return false;
244    }
245
246    /**
247     * Enqueues scripts and styles necessary for SSO login.
248     */
249    public function login_enqueue_scripts() {
250        global $action;
251
252        if ( ! Helpers::display_sso_form_for_action( $action ) ) {
253            return;
254        }
255
256        Assets::register_script(
257            'jetpack-sso-login',
258            '../../dist/jetpack-sso-login.js',
259            __FILE__,
260            array(
261                'enqueue' => true,
262                'version' => Package_Version::PACKAGE_VERSION,
263            )
264        );
265    }
266
267    /**
268     * Adds Jetpack SSO classes to login body
269     *
270     * @param  array $classes Array of classes to add to body tag.
271     * @return array          Array of classes to add to body tag.
272     */
273    public function login_body_class( $classes ) {
274        global $action;
275
276        if ( ! Helpers::display_sso_form_for_action( $action ) ) {
277            return $classes;
278        }
279
280        // Always add the jetpack-sso class so that we can add SSO specific styling even when the SSO form isn't being displayed.
281        $classes[] = 'jetpack-sso';
282
283        if ( ! ( new Status() )->in_safe_mode() ) {
284            /**
285             * Should we show the SSO login form?
286             *
287             * $_GET['jetpack-sso-default-form'] is used to provide a fallback in case JavaScript is not enabled.
288             *
289             * The default_to_sso_login() method allows us to dynamically decide whether we show the SSO login form or not.
290             * The SSO module uses the method to display the default login form if we cannot find a user to log in via SSO.
291             * But, the method could be filtered by a site admin to always show the default login form if that is preferred.
292             */
293            $default_form_preference = isset( $_GET['jetpack-sso-show-default-form'] ) ? sanitize_text_field( wp_unslash( $_GET['jetpack-sso-show-default-form'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
294            $show_sso_form           = empty( $default_form_preference ) && Helpers::show_sso_login();
295
296            if ( 'entered_recovery_mode' === $action ) {
297                if ( '0' === $default_form_preference ) {
298                    // Explicit user opt-in via the no-JS toggle; honor it regardless of show_sso_login() so the toggle always works.
299                    $show_sso_form = true;
300                } elseif ( null === $default_form_preference && ! Helpers::should_hide_login_form() ) {
301                    // Recovery is the break-glass fallback, so default to the wp-admin password form. Skip when that form is hidden, otherwise no login path would work.
302                    $show_sso_form = false;
303                }
304            }
305
306            if ( $show_sso_form ) {
307                $classes[] = 'jetpack-sso-form-display';
308            }
309        }
310
311        return $classes;
312    }
313
314    /**
315     * Print the SSO styles for the login screen.
316     *
317     * @deprecated 8.12.0 Use enqueue_login_styles().
318     */
319    public function print_inline_admin_css() {
320        _deprecated_function( __METHOD__, 'connection-8.12.0', __CLASS__ . '::enqueue_login_styles' );
321        $this->enqueue_login_styles();
322    }
323
324    /**
325     * Enqueue the SSO styles for the login screen.
326     */
327    public function enqueue_login_styles() {
328        $handle = 'jetpack-sso-login-styles';
329
330        // No src: the handle only carries the inline CSS below. Core enqueues `login` before `login_enqueue_scripts` fires,
331        // so these rules already print after the core login stylesheet, which sets `.message` margins at the same
332        // specificity. No dependency on `login`: plugins that replace the login screen deregister that handle, and a
333        // missing dependency would drop this one from the queue.
334        wp_register_style( $handle, false, array(), Package_Version::PACKAGE_VERSION );
335        wp_enqueue_style( $handle );
336
337        $css = <<<'CSS'
338.jetpack-sso .message {
339    margin-top: 20px;
340}
341
342.jetpack-sso #login .message:first-child,
343.jetpack-sso #login h1 + .message {
344    margin-top: 0;
345}
346CSS;
347
348        wp_add_inline_style( $handle, $css );
349    }
350
351    /**
352     * Adds settings fields to Settings > General > Secure Sign On that allows users to
353     * turn off the login form on wp-login.php
354     *
355     * @since jetpack-2.7
356     **/
357    public function register_settings() {
358
359        add_settings_section(
360            'jetpack_sso_settings',
361            __( 'Secure Sign On', 'jetpack-connection' ),
362            '__return_false',
363            'jetpack-sso'
364        );
365
366        /*
367         * Settings > General > Secure Sign On
368         * Require two step authentication
369         */
370        register_setting(
371            'jetpack-sso',
372            'jetpack_sso_require_two_step',
373            array( $this, 'validate_jetpack_sso_require_two_step' )
374        );
375
376        add_settings_field(
377            'jetpack_sso_require_two_step',
378            '', // Output done in render $callback: __( 'Require Two-Step Authentication' , 'jetpack-connection' ).
379            array( $this, 'render_require_two_step' ),
380            'jetpack-sso',
381            'jetpack_sso_settings'
382        );
383
384        /*
385         * Settings > General > Secure Sign On
386         */
387        register_setting(
388            'jetpack-sso',
389            'jetpack_sso_match_by_email',
390            array( $this, 'validate_jetpack_sso_match_by_email' )
391        );
392
393        add_settings_field(
394            'jetpack_sso_match_by_email',
395            '', // Output done in render $callback: __( 'Match by Email' , 'jetpack-connection' ).
396            array( $this, 'render_match_by_email' ),
397            'jetpack-sso',
398            'jetpack_sso_settings'
399        );
400    }
401
402    /**
403     * Builds the display for the checkbox allowing user to require two step
404     * auth be enabled on WordPress.com accounts before login. Displays in Settings > General
405     *
406     * @since jetpack-2.7
407     **/
408    public function render_require_two_step() {
409        ?>
410        <label>
411            <input
412                type="checkbox"
413                name="jetpack_sso_require_two_step"
414        <?php checked( Helpers::is_two_step_required() ); ?>
415        <?php disabled( Helpers::is_require_two_step_checkbox_disabled() ); ?>
416            >
417        <?php esc_html_e( 'Require Two-Step Authentication', 'jetpack-connection' ); ?>
418        </label>
419        <?php
420    }
421
422    /**
423     * Validate the require  two step checkbox in Settings > General.
424     *
425     * @param bool $input The jetpack_sso_require_two_step option setting.
426     *
427     * @since jetpack-2.7
428     * @return int
429     **/
430    public function validate_jetpack_sso_require_two_step( $input ) {
431        return ( ! empty( $input ) ) ? 1 : 0;
432    }
433
434    /**
435     * Builds the display for the checkbox allowing the user to allow matching logins by email
436     * Displays in Settings > General
437     *
438     * @since jetpack-2.9
439     **/
440    public function render_match_by_email() {
441        ?>
442            <label>
443                <input
444                    type="checkbox"
445                    name="jetpack_sso_match_by_email"
446            <?php checked( Helpers::match_by_email() ); ?>
447            <?php disabled( Helpers::is_match_by_email_checkbox_disabled() ); ?>
448                >
449        <?php esc_html_e( 'Match by Email', 'jetpack-connection' ); ?>
450            </label>
451        <?php
452    }
453
454    /**
455     * Validate the match by email check in Settings > General.
456     *
457     * @param bool $input The jetpack_sso_match_by_email option setting.
458     *
459     * @since jetpack-2.9
460     * @return int
461     **/
462    public function validate_jetpack_sso_match_by_email( $input ) {
463        return ( ! empty( $input ) ) ? 1 : 0;
464    }
465
466    /**
467     * Checks to determine if the user wants to login on wp-login
468     *
469     * This function mostly exists to cover the exceptions to login
470     * that may exist as other parameters to $_GET[action] as $_GET[action]
471     * does not have to exist. By default WordPress assumes login if an action
472     * is not set, however this may not be true, as in the case of logout
473     * where $_GET[loggedout] is instead set
474     *
475     * @return boolean
476     **/
477    private function wants_to_login() {
478        $wants_to_login = false;
479
480        // Cover default WordPress behavior.
481        $action = isset( $_REQUEST['action'] ) ? filter_var( wp_unslash( $_REQUEST['action'] ) ) : 'login'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
482
483        // And now the exceptions.
484        $action = isset( $_GET['loggedout'] ) ? 'loggedout' : $action; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
485
486        // Recovery mode must complete on the local site (token validation, cookie, recovery notice). Skip the bypass-redirect so SSO doesn't carry the user off-site mid-recovery.
487        if ( 'entered_recovery_mode' === $action ) {
488            return false;
489        }
490
491        if ( Helpers::display_sso_form_for_action( $action ) ) {
492            $wants_to_login = true;
493        }
494
495        return $wants_to_login;
496    }
497
498    /**
499     * Initialization for a SSO request.
500     */
501    public function login_init() {
502        global $action;
503
504        $tracking = new Tracking();
505
506        if ( Helpers::should_hide_login_form() ) {
507            /**
508             * Since the default authenticate filters fire at priority 20 for checking username and password,
509             * let's fire at priority 30. wp_authenticate_spam_check is fired at priority 99, but since we return a
510             * WP_Error in disable_default_login_form, then we won't trigger spam processing logic.
511             */
512            add_filter( 'authenticate', array( Notices::class, 'disable_default_login_form' ), 30 );
513
514            /**
515             * Filter the display of the disclaimer message appearing when default WordPress login form is disabled.
516             *
517             * @module sso
518             *
519             * @since jetpack-2.8.0
520             *
521             * @param bool true Should the disclaimer be displayed. Default to true.
522             */
523            $display_sso_disclaimer = apply_filters( 'jetpack_sso_display_disclaimer', true );
524            if ( $display_sso_disclaimer ) {
525                add_filter( 'login_message', array( Notices::class, 'msg_login_by_jetpack' ) );
526            }
527        }
528
529        if ( 'jetpack-sso' === $action ) {
530            if ( isset( $_GET['result'] ) && isset( $_GET['user_id'] ) && isset( $_GET['sso_nonce'] ) && 'success' === $_GET['result'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
531                $this->handle_login();
532                $this->display_sso_login_form();
533            } elseif ( ( new Status() )->in_safe_mode() ) {
534                add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
535            } else {
536                // Is it wiser to just use wp_redirect than do this runaround to wp_safe_redirect?
537                add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
538                $reauth  = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
539                $sso_url = $this->get_sso_url_or_die( $reauth );
540
541                $tracking->record_user_event( 'sso_login_redirect_success' );
542                wp_safe_redirect( $sso_url );
543                exit( 0 );
544            }
545        } elseif ( Helpers::display_sso_form_for_action( $action ) ) {
546
547            // Save cookies so we can handle redirects after SSO.
548            static::save_cookies();
549
550            /**
551             * Check to see if the site admin wants to automagically forward the user
552             * to the WordPress.com login page AND  that the request to wp-login.php
553             * is not something other than login (Like logout!)
554             */
555            if ( Helpers::bypass_login_forward_wpcom() && $this->wants_to_login() ) {
556                add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
557                $reauth  = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
558                $sso_url = $this->get_sso_url_or_die( $reauth );
559                $tracking->record_user_event( 'sso_login_redirect_bypass_success' );
560                wp_safe_redirect( $sso_url );
561                exit( 0 );
562            }
563
564            $this->display_sso_login_form();
565        }
566    }
567
568    /**
569     * Ensures that we can get a nonce from WordPress.com via XML-RPC before setting
570     * up the hooks required to display the SSO form.
571     */
572    public function display_sso_login_form() {
573        add_filter( 'login_body_class', array( $this, 'login_body_class' ) );
574        add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_styles' ) );
575
576        if ( ( new Status() )->in_safe_mode() ) {
577            add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
578            return;
579        }
580
581        $sso_nonce = self::request_initial_nonce();
582        if ( is_wp_error( $sso_nonce ) ) {
583            return;
584        }
585
586        add_action( 'login_form', array( $this, 'login_form' ) );
587        add_action( 'login_enqueue_scripts', array( $this, 'login_enqueue_scripts' ) );
588    }
589
590    /**
591     * Conditionally save the redirect_to url as a cookie.
592     *
593     * @since jetpack-4.6.0 Renamed to save_cookies from maybe_save_redirect_cookies
594     */
595    public static function save_cookies() {
596        if ( headers_sent() ) {
597            return new WP_Error( 'headers_sent', __( 'Cannot deal with cookie redirects, as headers are already sent.', 'jetpack-connection' ) );
598        }
599
600        setcookie(
601            'jetpack_sso_original_request',
602        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the wrapping esc_url_raw().
603            esc_url_raw( set_url_scheme( ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) ),
604            time() + HOUR_IN_SECONDS,
605            COOKIEPATH,
606            COOKIE_DOMAIN,
607            is_ssl(),
608            true
609        );
610
611        // Persist the WordPress.com referrer signal so it survives the SSO button
612        // click, which changes the HTTP Referer to the site's own login page.
613        // Uses the live-only check to avoid a self-reinforcing cookie loop.
614        if ( self::is_live_referrer_wpcom() ) {
615            setcookie( 'jetpack_sso_wpcom_referrer', '1', time() + ( 10 * MINUTE_IN_SECONDS ), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
616            $_COOKIE['jetpack_sso_wpcom_referrer'] = '1';
617        } elseif ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
618            setcookie( 'jetpack_sso_wpcom_referrer', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
619            unset( $_COOKIE['jetpack_sso_wpcom_referrer'] );
620        }
621
622        if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
623            // If we have something to redirect to.
624            $url = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
625            setcookie( 'jetpack_sso_redirect_to', $url, time() + HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
626        } elseif ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
627            // Otherwise, if it's already set, purge it.
628            setcookie( 'jetpack_sso_redirect_to', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
629        }
630    }
631
632    /**
633     * Outputs the Jetpack SSO button and description as well as the toggle link
634     * for switching between Jetpack SSO and default login.
635     */
636    public function login_form() {
637        $site_name = get_bloginfo( 'name' );
638        if ( ! $site_name ) {
639            $site_name = get_bloginfo( 'url' );
640        }
641
642        $display_name = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] )
643        ? sanitize_text_field( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] ) )
644        : false;
645        $gravatar     = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] )
646        ? esc_url_raw( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] ) )
647        : false;
648
649        ?>
650        <div id="jetpack-sso-wrap">
651        <?php
652        /**
653         * Allow extension above Jetpack's SSO form.
654         *
655         * @module sso
656         *
657         * @since jetpack-8.6.0
658         */
659        do_action( 'jetpack_sso_login_form_above_wpcom' );
660
661        if ( $display_name && $gravatar ) :
662            ?>
663                <div id="jetpack-sso-wrap__user">
664                    <img width="72" height="72" src="<?php echo esc_html( $gravatar ); ?>" />
665
666                    <h2>
667                <?php
668                echo wp_kses(
669                    /* translators: %s a user display name. */
670                    sprintf( __( 'Log in as <span>%s</span>', 'jetpack-connection' ), esc_html( $display_name ) ),
671                    array( 'span' => true )
672                );
673                ?>
674                    </h2>
675                </div>
676
677                <?php endif; ?>
678
679
680            <div id="jetpack-sso-wrap__action">
681                    <?php echo $this->build_sso_button( array(), true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaping done in build_sso_button() ?>
682
683                    <?php if ( $display_name && $gravatar ) : ?>
684                    <a rel="nofollow" class="jetpack-sso-wrap__reauth" href="<?php echo esc_url( $this->build_sso_button_url( array( 'force_reauth' => '1' ) ) ); ?>">
685                        <?php esc_html_e( 'Log in with another WordPress.com account', 'jetpack-connection' ); ?>
686                    </a>
687                <?php else : ?>
688                    <p>
689                        <?php
690                            /**
691                             * Filter the messeage displayed below the SSO button.
692                             *
693                             * @module sso
694                             *
695                             * @since jetpack-10.3.0
696                             *
697                             * @param string $sso_explanation Message displayed below the SSO button.
698                             */
699                            $sso_explanation = apply_filters(
700                                'jetpack_sso_login_form_explanation_text',
701                                sprintf(
702                                    /* Translators: %s is the name of the site. */
703                                    __( 'You can now save time spent logging in by connecting your WordPress.com account to %s.', 'jetpack-connection' ),
704                                    esc_html( $site_name )
705                                )
706                            );
707                            echo esc_html( $sso_explanation );
708                        ?>
709                    </p>
710                <?php endif; ?>
711            </div>
712
713                    <?php
714                    /**
715                     * Allow extension below Jetpack's SSO form.
716                     *
717                     * @module sso
718                     *
719                     * @since jetpack-8.6.0
720                     */
721                    do_action( 'jetpack_sso_login_form_below_wpcom' );
722
723                    if ( ! Helpers::should_hide_login_form() ) :
724                        ?>
725                    <div class="jetpack-sso-or">
726                        <span><?php esc_html_e( 'Or', 'jetpack-connection' ); ?></span>
727                    </div>
728
729                    <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '1' ) ); ?>" class="jetpack-sso-toggle wpcom">
730                        <?php
731                        esc_html_e( 'Log in with username and password', 'jetpack-connection' )
732                        ?>
733                    </a>
734
735                    <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '0' ) ); ?>" class="jetpack-sso-toggle default">
736                        <?php
737                        esc_html_e( 'Log in with WordPress.com', 'jetpack-connection' )
738                        ?>
739                    </a>
740                    <?php endif; ?>
741        </div>
742                <?php
743    }
744
745    /**
746     * Clear cookies that are no longer needed once the user has logged in.
747     *
748     * @since jetpack-4.8.0
749     */
750    public static function clear_cookies_after_login() {
751        Helpers::clear_wpcom_profile_cookies();
752        if ( isset( $_COOKIE['jetpack_sso_nonce'] ) ) {
753            setcookie(
754                'jetpack_sso_nonce',
755                ' ',
756                time() - YEAR_IN_SECONDS,
757                COOKIEPATH,
758                COOKIE_DOMAIN,
759                is_ssl(),
760                true
761            );
762        }
763
764        if ( isset( $_COOKIE['jetpack_sso_original_request'] ) ) {
765            setcookie(
766                'jetpack_sso_original_request',
767                ' ',
768                time() - YEAR_IN_SECONDS,
769                COOKIEPATH,
770                COOKIE_DOMAIN,
771                is_ssl(),
772                true
773            );
774        }
775
776        if ( isset( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
777            setcookie(
778                'jetpack_sso_redirect_to',
779                ' ',
780                time() - YEAR_IN_SECONDS,
781                COOKIEPATH,
782                COOKIE_DOMAIN,
783                is_ssl(),
784                true
785            );
786        }
787
788        if ( isset( $_COOKIE[ self::BROKER_COOKIE ] ) ) {
789            setcookie(
790                self::BROKER_COOKIE,
791                ' ',
792                time() - YEAR_IN_SECONDS,
793                COOKIEPATH,
794                COOKIE_DOMAIN,
795                is_ssl(),
796                true
797            );
798        }
799
800        if ( isset( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
801            setcookie(
802                'jetpack_sso_wpcom_referrer',
803                ' ',
804                time() - YEAR_IN_SECONDS,
805                COOKIEPATH,
806                COOKIE_DOMAIN,
807                is_ssl(),
808                true
809            );
810        }
811    }
812
813    /**
814     * Clean up after Jetpack gets disconnected.
815     *
816     * @since jetpack-10.7
817     */
818    public static function disconnect() {
819        if ( ( new Manager() )->is_user_connected() ) {
820            Helpers::delete_connection_for_user( get_current_user_id() );
821        }
822    }
823
824    /**
825     * Retrieves nonce used for SSO form.
826     *
827     * @return string|WP_Error
828     */
829    public static function request_initial_nonce() {
830        $nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] )
831        ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) )
832        : false;
833
834        if ( ! $nonce ) {
835            $xml = new Jetpack_IXR_Client();
836            $xml->query( 'jetpack.sso.requestNonce' );
837
838            if ( $xml->isError() ) {
839                return new WP_Error( $xml->getErrorCode(), $xml->getErrorMessage() );
840            }
841
842            $response = $xml->getResponse();
843
844            // The response may be a plain nonce string (default) or an associative
845            // array containing 'nonce' and a 'use_sso_broker' signal for sites that
846            // use an external SSO broker (e.g. CIAB stores via the MSD).
847            if ( is_array( $response ) ) {
848                if ( empty( $response['nonce'] ) ) {
849                    return new WP_Error( 'invalid_response', __( 'Invalid nonce response from WordPress.com.', 'jetpack-connection' ) );
850                }
851
852                $nonce      = sanitize_key( $response['nonce'] );
853                $use_broker = ! empty( $response['use_sso_broker'] );
854            } else {
855                $nonce      = sanitize_key( $response );
856                $use_broker = false;
857            }
858
859            $cookie_expiry = time() + ( 10 * MINUTE_IN_SECONDS );
860
861            setcookie(
862                'jetpack_sso_nonce',
863                $nonce,
864                $cookie_expiry,
865                COOKIEPATH,
866                COOKIE_DOMAIN,
867                is_ssl(),
868                true
869            );
870            // Ensure this request can use the nonce immediately after setcookie().
871            $_COOKIE['jetpack_sso_nonce'] = $nonce;
872
873            if ( $use_broker ) {
874                setcookie(
875                    self::BROKER_COOKIE,
876                    $nonce,
877                    $cookie_expiry,
878                    COOKIEPATH,
879                    COOKIE_DOMAIN,
880                    is_ssl(),
881                    true
882                );
883                // Mirror the broker signal in-memory for this request.
884                $_COOKIE[ self::BROKER_COOKIE ] = $nonce;
885            } else {
886                setcookie( self::BROKER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
887                unset( $_COOKIE[ self::BROKER_COOKIE ] );
888            }
889        }
890
891        return $nonce;
892    }
893
894    /**
895     * Validates a broker URL string.
896     *
897     * @param string $url The URL to validate.
898     * @return string|false The URL if valid HTTPS with a host, or false.
899     */
900    private static function validate_broker_url( $url ) {
901        if ( empty( $url ) || ! is_string( $url ) ) {
902            return false;
903        }
904
905        $sanitized = esc_url_raw( $url );
906        $url_parts = wp_parse_url( $sanitized );
907
908        if ( $url_parts && 'https' === ( $url_parts['scheme'] ?? '' ) && ! empty( $url_parts['host'] ) ) {
909            return $sanitized;
910        }
911
912        return false;
913    }
914
915    /**
916     * Checks whether WP.com has authorized broker mode for the current SSO flow.
917     *
918     * The broker cookie is set during the nonce request when WP.com signals
919     * that broker SSO should be used. Its value matches the SSO nonce to tie
920     * the authorization to a specific flow.
921     *
922     * @return bool True if WP.com authorized broker mode for this nonce.
923     */
924    private static function is_broker_authorized() {
925        $broker_signal = ! empty( $_COOKIE[ self::BROKER_COOKIE ] )
926            ? sanitize_key( wp_unslash( $_COOKIE[ self::BROKER_COOKIE ] ) )
927            : false;
928        $current_nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] )
929            ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) )
930            : false;
931
932        return $broker_signal && $current_nonce && $broker_signal === $current_nonce;
933    }
934
935    /**
936     * Checks whether the current request's referrer is a WordPress.com domain.
937     *
938     * Used to skip the broker URL when the user navigated from Calypso or
939     * another WordPress.com interface, so they stay within the expected
940     * wordpress.com SSO flow.
941     *
942     * @return bool True if the referrer is a WordPress.com domain.
943     */
944    private static function is_referrer_wpcom() {
945        // Check the cookie persisted by save_cookies() on the initial login page
946        // load. The live HTTP Referer changes to the site's own wp-login.php when
947        // the user clicks the SSO button, so the cookie carries the original signal.
948        if ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
949            return true;
950        }
951
952        return self::is_live_referrer_wpcom();
953    }
954
955    /**
956     * Checks the live HTTP Referer header against WordPress.com domains.
957     *
958     * Unlike is_referrer_wpcom(), this does NOT consult the persisted cookie,
959     * so it is safe to call from save_cookies() without creating a
960     * self-reinforcing loop.
961     *
962     * @return bool True if the live referrer is a WordPress.com domain.
963     */
964    private static function is_live_referrer_wpcom() {
965        $referer = wp_get_raw_referer();
966        if ( ! $referer ) {
967            return false;
968        }
969
970        $wpcom_hosts = array(
971            'wordpress.com',
972            'horizon.wordpress.com',
973            'wpcalypso.wordpress.com',
974        );
975
976        $referer_host = wp_parse_url( $referer, PHP_URL_HOST );
977        return $referer_host && in_array( $referer_host, $wpcom_hosts, true );
978    }
979
980    /**
981     * Retrieves the SSO broker URL if authorized by WP.com and defined by the MU plugin.
982     *
983     * The broker URL is read from the JETPACK_SSO_BROKER_URL constant, which
984     * is expected to be defined by a garden MU plugin (e.g. for CIAB stores).
985     * It is only used when WP.com has signaled broker mode via the nonce response.
986     *
987     * @return string|false The broker URL, or false if not available.
988     */
989    public static function get_broker_url() {
990        if ( ! self::is_broker_authorized() ) {
991            return false;
992        }
993        $url = Constants::get_constant( 'JETPACK_SSO_BROKER_URL' );
994        return $url ? self::validate_broker_url( $url ) : false;
995    }
996
997    /**
998     * Retrieves the SSO broker authorization URL if authorized by WP.com.
999     *
1000     * For broker sites, this URL replaces the Jetpack authorization endpoint
1001     * for establishing user connections. Read from the JETPACK_SSO_BROKER_AUTH_URL
1002     * constant defined by the garden MU plugin.
1003     *
1004     * @return string|false The broker authorization URL, or false if not available.
1005     */
1006    public static function get_broker_auth_url() {
1007        if ( ! self::is_broker_authorized() ) {
1008            return false;
1009        }
1010        $url = Constants::get_constant( 'JETPACK_SSO_BROKER_AUTH_URL' );
1011        return $url ? self::validate_broker_url( $url ) : false;
1012    }
1013
1014    /**
1015     * The function that actually handles the login!
1016     */
1017    public function handle_login() {
1018        $wpcom_nonce   = isset( $_GET['sso_nonce'] ) ? sanitize_key( $_GET['sso_nonce'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1019        $wpcom_user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1020
1021        $token_lookup             = $this->get_signed_user_token_for_wpcom_id( $wpcom_user_id );
1022        $signed_user_token        = $token_lookup['signed_token'];
1023        $token_validated_for_user = $token_lookup['local_user_id'];
1024
1025        $xml = new Jetpack_IXR_Client();
1026        if ( $signed_user_token ) {
1027            $xml->query( 'jetpack.sso.validateResult', $wpcom_nonce, $wpcom_user_id, $signed_user_token );
1028        } else {
1029            $xml->query( 'jetpack.sso.validateResult', $wpcom_nonce, $wpcom_user_id );
1030        }
1031
1032        $user_data = $xml->isError() ? false : $xml->getResponse();
1033        if ( empty( $user_data ) ) {
1034            add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1035            add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) );
1036            return;
1037        }
1038
1039        $user_data = (object) $user_data;
1040        $user      = null;
1041
1042        /**
1043         * Fires before Jetpack's SSO modifies the log in form.
1044         *
1045         * @module sso
1046         *
1047         * @since jetpack-2.6.0
1048         *
1049         * @param object $user_data WordPress.com User information.
1050         */
1051        do_action( 'jetpack_sso_pre_handle_login', $user_data );
1052
1053        $tracking = new Tracking();
1054
1055        if ( Helpers::is_two_step_required() && 0 === (int) $user_data->two_step_enabled ) {
1056            $this->user_data = $user_data;
1057
1058            $tracking->record_user_event(
1059                'sso_login_failed',
1060                array(
1061                    'error_message' => 'error_msg_enable_two_step',
1062                )
1063            );
1064
1065            $error = new WP_Error( 'two_step_required', __( 'You must have Two-Step Authentication enabled on your WordPress.com account.', 'jetpack-connection' ) );
1066
1067            /** This filter is documented in core/src/wp-includes/pluggable.php */
1068            do_action( 'wp_login_failed', $user_data->login, $error );
1069            add_filter( 'login_message', array( Notices::class, 'error_msg_enable_two_step' ) );
1070            return;
1071        }
1072
1073        $user_found_with = '';
1074        if ( isset( $user_data->external_user_id ) ) {
1075            $user_found_with = 'external_user_id';
1076            $user            = get_user_by( 'id', (int) $user_data->external_user_id );
1077            if ( $user ) {
1078                $expected_id = Utils::get_wpcom_user_id( $user->ID );
1079                if ( $expected_id && $expected_id !== (int) $user_data->ID ) {
1080                    $error = new WP_Error( 'expected_wpcom_user', __( 'Something got a little mixed up and an unexpected WordPress.com user logged in.', 'jetpack-connection' ) );
1081
1082                    $tracking->record_user_event(
1083                        'sso_login_failed',
1084                        array(
1085                            'error_message' => 'error_unexpected_wpcom_user',
1086                        )
1087                    );
1088
1089                    /** This filter is documented in core/src/wp-includes/pluggable.php */
1090                    do_action( 'wp_login_failed', $user_data->login, $error );
1091                    add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) ); // @todo Need to have a better notice. This is only for the sake of testing the validation.
1092                    return;
1093                }
1094                self::set_wpcom_user_id_meta( $user->ID, $user_data->ID );
1095            }
1096        }
1097
1098        // If we don't have one by wpcom_user_id, try by the email?
1099        if ( empty( $user ) && Helpers::match_by_email() ) {
1100            $user_found_with = 'match_by_email';
1101            $user            = get_user_by( 'email', $user_data->email );
1102            if ( $user ) {
1103                self::set_wpcom_user_id_meta( $user->ID, $user_data->ID );
1104            }
1105        }
1106
1107        // If we've still got nothing, create the user.
1108        $new_user_override_role = Helpers::new_user_override( $user_data );
1109        if ( empty( $user ) && ( get_option( 'users_can_register' ) || $new_user_override_role ) ) {
1110            /**
1111             * If not matching by email we still need to verify the email does not exist
1112             * or this blows up
1113             *
1114             * If match_by_email is true, we know the email doesn't exist, as it would have
1115             * been found in the first pass.  If get_user_by( 'email' ) doesn't find the
1116             * user, then we know that email is unused, so it's safe to add.
1117             */
1118            if ( Helpers::match_by_email() || ! get_user_by( 'email', $user_data->email ) ) {
1119
1120                if ( $new_user_override_role ) {
1121                    $user_data->role = $new_user_override_role;
1122                }
1123
1124                $user = Utils::generate_user( $user_data );
1125                if ( ! $user ) {
1126                    $tracking->record_user_event(
1127                        'sso_login_failed',
1128                        array(
1129                            'error_message' => 'could_not_create_username',
1130                        )
1131                    );
1132                    add_filter( 'login_message', array( Notices::class, 'error_unable_to_create_user' ) );
1133                    return;
1134                }
1135
1136                $user_found_with = $new_user_override_role
1137                ? 'user_created_new_user_override'
1138                : 'user_created_users_can_register';
1139            } else {
1140                $tracking->record_user_event(
1141                    'sso_login_failed',
1142                    array(
1143                        'error_message' => 'error_msg_email_already_exists',
1144                    )
1145                );
1146
1147                $this->user_data = $user_data;
1148                add_action( 'login_message', array( Notices::class, 'error_msg_email_already_exists' ) );
1149                return;
1150            }
1151        }
1152
1153        /**
1154         * Fires after we got login information from WordPress.com.
1155         *
1156         * @module sso
1157         *
1158         * @since jetpack-2.6.0
1159         *
1160         * @param WP_User|false|null $user      Local User information.
1161         * @param object             $user_data WordPress.com User Login information.
1162         */
1163        do_action( 'jetpack_sso_handle_login', $user, $user_data );
1164
1165        if ( $user ) {
1166            // Cache the user's details, so we can present it back to them on their user screen.
1167            update_user_meta( $user->ID, 'wpcom_user_data', $user_data );
1168
1169            /*
1170             * Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX,
1171             * which destroys the auth session and prompts for local 2FA â€” even for SSO
1172             * logins that already completed 2FA on WordPress.com.
1173             *
1174             * When WP.com confirms the user has 2FA active, remove Two-Factor's wp_login
1175             * hook so SSO can complete without a redundant local 2FA prompt.
1176             *
1177             * When WP.com 2FA is NOT active, the hook stays and Two-Factor can enforce
1178             * local 2FA as a safety net.
1179             *
1180             * @see https://github.com/WordPress/two-factor/issues/811
1181             */
1182            /**
1183             * Filter whether to accept WordPress.com 2FA in place of a local
1184             * Two-Factor prompt during SSO login.
1185             *
1186             * Return false to always require the local Two-Factor prompt,
1187             * even when the user has completed 2FA on WordPress.com.
1188             *
1189             * @since 8.1.0
1190             * @module sso
1191             *
1192             * @param bool    $accept    Whether to accept WP.com 2FA. Default true.
1193             * @param object  $user_data WordPress.com user data from SSO validation.
1194             * @param WP_User $user      The local WordPress user.
1195             */
1196            $accept_wpcom_2fa = apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user );
1197
1198            if (
1199                ! empty( $user_data->two_step_enabled )
1200                && class_exists( 'Two_Factor_Core' )
1201                && $accept_wpcom_2fa
1202            ) {
1203                self::$sso_user_for_2fa = $user;
1204                add_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10, 2 );
1205
1206                remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX );
1207            }
1208
1209            add_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1210            wp_set_auth_cookie( $user->ID, true );
1211            remove_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1212            remove_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10 );
1213
1214            /** This filter is documented in core/src/wp-includes/user.php */
1215            do_action( 'wp_login', $user->user_login, $user );
1216
1217            wp_set_current_user( $user->ID );
1218
1219            $json_api_auth_environment = Helpers::get_json_api_auth_environment();
1220
1221            $is_json_api_auth  = ! empty( $json_api_auth_environment );
1222            $manager           = new Manager();
1223            $is_user_connected = $manager->is_user_connected( $user->ID );
1224
1225            if ( $is_user_connected ) {
1226                $is_user_connected = $this->verify_user_token(
1227                    $user->ID,
1228                    $user_data,
1229                    $manager->get_tokens(),
1230                    $token_validated_for_user
1231                );
1232            }
1233
1234            $roles = new Roles();
1235            $tracking->record_user_event(
1236                'sso_user_logged_in',
1237                array(
1238                    'user_found_with'  => $user_found_with,
1239                    'user_connected'   => (bool) $is_user_connected,
1240                    'user_role'        => $roles->translate_current_user_to_role(),
1241                    'is_json_api_auth' => $is_json_api_auth,
1242                )
1243            );
1244
1245            $_request_redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1246            $redirect_to          = user_can( $user, 'edit_posts' ) ? admin_url() : self::profile_page_url();
1247
1248            // If we have a saved redirect to request in a cookie.
1249            if ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
1250                // Set that as the requested redirect to.
1251                $redirect_to          = esc_url_raw( wp_unslash( $_COOKIE['jetpack_sso_redirect_to'] ) );
1252                $_request_redirect_to = $redirect_to;
1253            }
1254
1255            if ( $is_json_api_auth ) {
1256                $authorize_json_api = new Authorize_Json_Api();
1257                $authorize_json_api->verify_json_api_authorization_request( $json_api_auth_environment );
1258                $authorize_json_api->store_json_api_authorization_token( $user->user_login, $user );
1259
1260            } elseif ( ! $is_user_connected ) {
1261                $broker_auth_url = self::get_broker_auth_url();
1262                if ( $broker_auth_url ) {
1263                    add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1264                    wp_safe_redirect(
1265                        add_query_arg(
1266                            array(
1267                                'action'                   => 'jetpack-sso',
1268                                'site_id'                  => Manager::get_site_id( true ),
1269                                'redirect_to'              => $redirect_to,
1270                                'request_redirect_to'      => $_request_redirect_to,
1271                                'broker-sso-auth-redirect' => '1',
1272                            ),
1273                            $broker_auth_url
1274                        )
1275                    );
1276                    exit( 0 );
1277                }
1278
1279                wp_safe_redirect(
1280                    add_query_arg(
1281                        array(
1282                            'redirect_to'               => $redirect_to,
1283                            'request_redirect_to'       => $_request_redirect_to,
1284                            'calypso_env'               => ( new Host() )->get_calypso_env(),
1285                            'jetpack-sso-auth-redirect' => '1',
1286                        ),
1287                        admin_url()
1288                    )
1289                );
1290                exit( 0 );
1291            }
1292
1293            add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1294            wp_safe_redirect(
1295            /** This filter is documented in core/src/wp-login.php */
1296                apply_filters( 'login_redirect', $redirect_to, $_request_redirect_to, $user )
1297            );
1298            exit( 0 );
1299        }
1300
1301        add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1302
1303        $tracking->record_user_event(
1304            'sso_login_failed',
1305            array(
1306                'error_message' => 'cant_find_user',
1307            )
1308        );
1309
1310        $this->user_data = $user_data;
1311
1312        $error = new WP_Error( 'account_not_found', __( 'Account not found. If you already have an account, make sure you have connected to WordPress.com.', 'jetpack-connection' ) );
1313
1314        /** This filter is documented in core/src/wp-includes/pluggable.php */
1315        do_action( 'wp_login_failed', $user_data->login, $error );
1316        add_filter( 'login_message', array( Notices::class, 'cant_find_user' ) );
1317    }
1318
1319    /**
1320     * Retrieve the admin profile page URL.
1321     */
1322    public static function profile_page_url() {
1323        return admin_url( 'profile.php' );
1324    }
1325
1326    /**
1327     * Builds the "Login to WordPress.com" button that is displayed on the login page as well as user profile page.
1328     *
1329     * @param  array   $args       An array of arguments to add to the SSO URL.
1330     * @param  boolean $is_primary If the button have the `button-primary` class.
1331     * @return string              Returns the HTML markup for the button.
1332     */
1333    public function build_sso_button( $args = array(), $is_primary = false ) {
1334        $url     = $this->build_sso_button_url( $args );
1335        $classes = $is_primary
1336        ? 'jetpack-sso button button-primary'
1337        : 'jetpack-sso button';
1338
1339        return sprintf(
1340            '<a rel="nofollow" href="%1$s" class="%2$s">%3$s %4$s</a>',
1341            esc_url( $url ),
1342            $classes,
1343            '<span class="genericon genericon-wordpress"></span>',
1344            esc_html__( 'Log in with WordPress.com', 'jetpack-connection' )
1345        );
1346    }
1347
1348    /**
1349     * Builds a URL with `jetpack-sso` action and option args which is used to setup SSO.
1350     *
1351     * @param  array $args An array of arguments to add to the SSO URL.
1352     * @return string       The URL used for SSO.
1353     */
1354    public function build_sso_button_url( $args = array() ) {
1355        $defaults = array(
1356            'action' => 'jetpack-sso',
1357        );
1358
1359        $args = wp_parse_args( $args, $defaults );
1360
1361        if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1362            $args['redirect_to'] = rawurlencode( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1363        }
1364
1365        return add_query_arg( $args, wp_login_url() );
1366    }
1367
1368    /**
1369     * Retrieves a WordPress.com SSO URL with appropriate query parameters or dies.
1370     *
1371     * @param  boolean $reauth  If the user be forced to reauthenticate on WordPress.com.
1372     * @param  array   $args    Optional query parameters.
1373     * @return string            The WordPress.com SSO URL.
1374     */
1375    public function get_sso_url_or_die( $reauth = false, $args = array() ) {
1376        $custom_login_url = Helpers::get_custom_login_url();
1377        if ( $custom_login_url ) {
1378            $args['login_url'] = rawurlencode( $custom_login_url );
1379        }
1380
1381        if ( empty( $reauth ) ) {
1382            $sso_redirect = $this->build_sso_url( $args );
1383        } else {
1384            Helpers::clear_wpcom_profile_cookies();
1385            $sso_redirect = $this->build_reauth_and_sso_url( $args );
1386        }
1387
1388        // If there was an error retrieving the SSO URL, then error.
1389        if ( is_wp_error( $sso_redirect ) ) {
1390            $error_message = sanitize_text_field(
1391                sprintf( '%s: %s', $sso_redirect->get_error_code(), $sso_redirect->get_error_message() )
1392            );
1393            $tracking      = new Tracking();
1394            $tracking->record_user_event(
1395                'sso_login_redirect_failed',
1396                array(
1397                    'error_message' => $error_message,
1398                )
1399            );
1400            wp_die( esc_html( $error_message ) );
1401        }
1402
1403        return $sso_redirect;
1404    }
1405
1406    /**
1407     * Returns the base URL for SSO authentication.
1408     *
1409     * If a broker URL is available (authorized by WP.com and defined by the
1410     * garden MU plugin), that URL is used unless the user navigated from a
1411     * WordPress.com domain. Otherwise falls back to the default WordPress.com
1412     * login URL.
1413     *
1414     * @return string The base SSO URL.
1415     */
1416    public static function get_sso_base_url() {
1417        $broker_url = self::get_broker_url();
1418        if ( $broker_url && ! self::is_referrer_wpcom() ) {
1419            return $broker_url;
1420        }
1421        return 'https://wordpress.com/wp-login.php';
1422    }
1423
1424    /**
1425     * Build SSO URL with appropriate query parameters.
1426     *
1427     * The base URL can be WordPress.com or an authorized broker URL.
1428     *
1429     * @param array $args Optional query parameters.
1430     * @return string|WP_Error Redirect URL for SSO authentication.
1431     */
1432    public function build_sso_url( $args = array() ) {
1433        $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1434        $defaults  = array(
1435            'action'       => 'jetpack-sso',
1436            'site_id'      => Manager::get_site_id( true ),
1437            'sso_nonce'    => $sso_nonce,
1438            'calypso_auth' => '1',
1439        );
1440
1441        $args = wp_parse_args( $args, $defaults );
1442
1443        if ( is_wp_error( $sso_nonce ) ) {
1444            return $sso_nonce;
1445        }
1446
1447        return add_query_arg( $args, self::get_sso_base_url() );
1448    }
1449
1450    /**
1451     * Build SSO URL with appropriate query parameters, including the
1452     * parameters necessary to force the user to reauthenticate.
1453     *
1454     * @param array $args Optional query parameters.
1455     * @return string|WP_Error Redirect URL for SSO authentication.
1456     */
1457    public function build_reauth_and_sso_url( $args = array() ) {
1458        $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1459        $redirect  = $this->build_sso_url(
1460            array(
1461                'force_auth' => '1',
1462                'sso_nonce'  => $sso_nonce,
1463            )
1464        );
1465
1466        if ( is_wp_error( $redirect ) ) {
1467            return $redirect;
1468        }
1469
1470        $defaults = array(
1471            'action'       => 'jetpack-sso',
1472            'site_id'      => Manager::get_site_id( true ),
1473            'sso_nonce'    => $sso_nonce,
1474            'reauth'       => '1',
1475            'redirect_to'  => rawurlencode( $redirect ),
1476            'calypso_auth' => '1',
1477        );
1478
1479        $args = wp_parse_args( $args, $defaults );
1480
1481        if ( is_wp_error( $args['sso_nonce'] ) ) {
1482            return $args['sso_nonce'];
1483        }
1484
1485        return add_query_arg( $args, self::get_sso_base_url() );
1486    }
1487
1488    /**
1489     * Sets the wpcom_user_id meta on a local user.
1490     *
1491     * @since 8.6.0
1492     *
1493     * @param int $user_id       The local WordPress user ID to set the meta on.
1494     * @param int $wpcom_user_id The WordPress.com user ID.
1495     */
1496    private static function set_wpcom_user_id_meta( $user_id, $wpcom_user_id ) {
1497        Utils::set_wpcom_user_id( $user_id, $wpcom_user_id );
1498    }
1499
1500    /**
1501     * Determines local user associated with a given WordPress.com user ID.
1502     *
1503     * @since jetpack-2.6.0
1504     *
1505     * @param int $wpcom_user_id User ID from WordPress.com.
1506     * @return null|object Local user object if found, null if not.
1507     */
1508    public static function get_user_by_wpcom_id( $wpcom_user_id ) {
1509        $user_query = new WP_User_Query(
1510            array(
1511                'meta_key'   => 'wpcom_user_id',
1512                'meta_value' => (int) $wpcom_user_id,
1513                'number'     => 1,
1514            )
1515        );
1516
1517        $users = $user_query->get_results();
1518        return $users ? array_shift( $users ) : null;
1519    }
1520
1521    /**
1522     * Retrieves the signed user token for a given WP.com user ID, if one exists locally.
1523     *
1524     * Looks up the local WordPress user associated with the WP.com user ID and returns
1525     * a signed representation of their user token along with the local user ID.
1526     * The signed token is sent to WP.com during SSO validation so WP.com can verify
1527     * the token is still valid on its side.
1528     *
1529     * @since 8.6.0
1530     *
1531     * @param int $wpcom_user_id The WordPress.com user ID.
1532     * @return array{signed_token: string, local_user_id: int} The signed token and local user ID.
1533     *               Both values are 0/empty when no valid token exists.
1534     */
1535    private function get_signed_user_token_for_wpcom_id( $wpcom_user_id ) {
1536        $result = array(
1537            'signed_token'  => '',
1538            'local_user_id' => 0,
1539        );
1540
1541        if ( ! $wpcom_user_id ) {
1542            return $result;
1543        }
1544
1545        $local_user = self::get_user_by_wpcom_id( $wpcom_user_id );
1546        if ( ! $local_user ) {
1547            return $result;
1548        }
1549
1550        $tokens     = new Tokens();
1551        $user_token = $tokens->get_access_token( $local_user->ID );
1552        if ( ! $user_token ) {
1553            return $result;
1554        }
1555
1556        $signed = $tokens->get_signed_token( $user_token );
1557        if ( is_wp_error( $signed ) ) {
1558            return $result;
1559        }
1560
1561        $result['signed_token']  = $signed;
1562        $result['local_user_id'] = $local_user->ID;
1563        return $result;
1564    }
1565
1566    /**
1567     * Verifies that a locally-stored user token is still valid on WP.com.
1568     *
1569     * Uses the `user_token_valid` field from the SSO validate response when the
1570     * signed token was sent for the same user that was resolved during login.
1571     *
1572     * When the validate response can't be trusted for this user (a different user
1573     * was resolved, or no signed token was sent), login proceeds without an extra
1574     * verification call. In that case `set_wpcom_user_id_meta()` records the
1575     * mapping during this login, so the fast path validates the token on the next
1576     * SSO login. This avoids an extra HTTP request on every first-SSO login and
1577     * keeps the Error_Handler from being triggered for users whose token state can
1578     * only be resolved by a direct token-health check.
1579     *
1580     * If the token is found to be invalid, it is removed locally so the user will be
1581     * prompted to re-authorize and obtain a fresh token.
1582     *
1583     * @since 8.6.0
1584     *
1585     * @param int    $user_id                  The local WordPress user ID (the resolved user).
1586     * @param object $user_data                The WP.com user data from jetpack.sso.validateResult.
1587     * @param Tokens $tokens                   The Tokens instance.
1588     * @param int    $token_validated_for_user  The local user ID whose token was sent to WP.com, or 0 if none.
1589     * @return bool True if the user token is valid (or could not be verified), false if invalid and removed.
1590     */
1591    private function verify_user_token( $user_id, $user_data, Tokens $tokens, $token_validated_for_user ) {
1592        // Only trust the validateResult response if the signed token was for this same user.
1593        if ( $token_validated_for_user === $user_id && isset( $user_data->user_token_valid ) ) {
1594            if ( false === $user_data->user_token_valid ) {
1595                $tokens->disconnect_user( $user_id );
1596                return false;
1597            }
1598            return true;
1599        }
1600
1601        // The signed token was for a different user (or wasn't sent at all), so the
1602        // validateResult response can't be trusted for this user. Let login proceed;
1603        // the wpcom_user_id meta set during this login means the next SSO login will
1604        // validate the token via the fast path.
1605        return true;
1606    }
1607
1608    /**
1609     * When jetpack-sso-auth-redirect query parameter is set, will redirect user to
1610     * WordPress.com authorization flow.
1611     *
1612     * We redirect here instead of in handle_login() because Jetpack::init()->build_connect_url
1613     * calls menu_page_url() which doesn't work properly until admin menus are registered.
1614     */
1615    public function maybe_authorize_user_after_sso() {
1616        if ( empty( $_GET['jetpack-sso-auth-redirect'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1617            return;
1618        }
1619
1620        $redirect_to         = ! empty( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1621        $request_redirect_to = ! empty( $_GET['request_redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['request_redirect_to'] ) ) : $redirect_to; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1622
1623        /** This filter is documented in core/src/wp-login.php */
1624        $redirect_after_auth = apply_filters( 'login_redirect', $redirect_to, $request_redirect_to, wp_get_current_user() );
1625
1626        /**
1627         * Since we are passing this redirect to WordPress.com and therefore cannot use wp_safe_redirect(),
1628         * let's sanitize it here to make sure it's safe. If the redirect is not safe, then use admin_url().
1629         */
1630        $redirect_after_auth = wp_sanitize_redirect( $redirect_after_auth );
1631        $redirect_after_auth = wp_validate_redirect( $redirect_after_auth, admin_url() );
1632
1633        /**
1634         * Return the raw connect URL with our redirect and attribute connection to SSO.
1635         * We remove any other filters that may be turning on the in-place connection
1636         * since we will be redirecting the user as opposed to iFraming.
1637         */
1638        remove_all_filters( 'jetpack_use_iframe_authorization_flow' );
1639        add_filter( 'jetpack_use_iframe_authorization_flow', '__return_false' );
1640
1641        $connection  = new Manager( 'jetpack-connection' );
1642        $connect_url = ( new Authorize_Redirect( $connection ) )->build_authorize_url( $redirect_after_auth, 'sso', true );
1643
1644        add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1645        wp_safe_redirect( $connect_url );
1646        exit( 0 );
1647    }
1648
1649    /**
1650     * Cache user's display name and Gravatar so it can be displayed on the login screen. These cookies are
1651     * stored when the user logs out, and then deleted when the user logs in.
1652     */
1653    public function store_wpcom_profile_cookies_on_logout() {
1654        $user_id = get_current_user_id();
1655        if ( ! ( new Manager() )->is_user_connected( $user_id ) ) {
1656            return;
1657        }
1658
1659        $user_data = $this->get_user_data( $user_id );
1660        if ( ! $user_data ) {
1661            return;
1662        }
1663
1664        setcookie(
1665            'jetpack_sso_wpcom_name_' . COOKIEHASH,
1666            $user_data->display_name,
1667            time() + WEEK_IN_SECONDS,
1668            COOKIEPATH,
1669            COOKIE_DOMAIN,
1670            is_ssl(),
1671            true
1672        );
1673
1674        setcookie(
1675            'jetpack_sso_wpcom_gravatar_' . COOKIEHASH,
1676            get_avatar_url(
1677                $user_data->email,
1678                array(
1679                    'size'    => 144,
1680                    'default' => 'mystery',
1681                )
1682            ),
1683            time() + WEEK_IN_SECONDS,
1684            COOKIEPATH,
1685            COOKIE_DOMAIN,
1686            is_ssl(),
1687            true
1688        );
1689    }
1690
1691    /**
1692     * Determines if a local user is connected to WordPress.com
1693     *
1694     * @since jetpack-2.8
1695     * @param integer $user_id - Local user id.
1696     * @return boolean
1697     **/
1698    public function is_user_connected( $user_id ) {
1699        return $this->get_user_data( $user_id );
1700    }
1701
1702    /**
1703     * Retrieves a user's WordPress.com data
1704     *
1705     * @since jetpack-2.8
1706     * @param integer $user_id - Local user id.
1707     * @return mixed null or stdClass
1708     **/
1709    public function get_user_data( $user_id ) {
1710        return get_user_meta( $user_id, 'wpcom_user_data', true );
1711    }
1712
1713    /**
1714     * Marks a session as two-factor-authenticated when SSO handled 2FA via WP.com.
1715     *
1716     * @param array $session Session information array.
1717     * @param int   $user_id User ID for the session being created.
1718     * @return array Modified session information.
1719     */
1720    public static function add_two_factor_session_meta( $session, $user_id ) {
1721        if ( self::$sso_user_for_2fa && self::$sso_user_for_2fa->ID === $user_id ) {
1722            $session['two-factor-login'] = time();
1723            self::$sso_user_for_2fa      = null;
1724        }
1725        return $session;
1726    }
1727}