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